create-stackr 0.2.0 → 0.3.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 (127) hide show
  1. package/README.md +10 -0
  2. package/dist/prompts/features.d.ts +1 -1
  3. package/dist/prompts/features.d.ts.map +1 -1
  4. package/dist/prompts/features.js +34 -25
  5. package/dist/prompts/features.js.map +1 -1
  6. package/dist/prompts/index.js +33 -6
  7. package/dist/prompts/index.js.map +1 -1
  8. package/dist/prompts/preset.d.ts.map +1 -1
  9. package/dist/prompts/preset.js +69 -34
  10. package/dist/prompts/preset.js.map +1 -1
  11. package/dist/utils/template.js +1 -1
  12. package/dist/utils/template.js.map +1 -1
  13. package/dist/utils/validation.d.ts.map +1 -1
  14. package/dist/utils/validation.js +43 -1
  15. package/dist/utils/validation.js.map +1 -1
  16. package/package.json +1 -1
  17. package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +33 -1
  18. package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +6 -0
  19. package/templates/base/backend/domain/user/repository.drizzle.ts +28 -1
  20. package/templates/base/backend/domain/user/repository.prisma.ts +25 -0
  21. package/templates/base/backend/drizzle/{schema.drizzle.ts → schema.drizzle.ts.ejs} +25 -0
  22. package/templates/base/backend/lib/auth.drizzle.ts.ejs +27 -18
  23. package/templates/base/backend/lib/auth.prisma.ts.ejs +24 -18
  24. package/templates/base/backend/package.json.ejs +29 -23
  25. package/templates/base/backend/prisma/schema.prisma.ejs +20 -0
  26. package/templates/base/mobile/app/+not-found.tsx +1 -1
  27. package/templates/base/mobile/app/_layout.tsx.ejs +95 -10
  28. package/templates/base/mobile/package.json.ejs +21 -13
  29. package/templates/base/mobile/src/components/ui/Button.tsx +5 -3
  30. package/templates/base/mobile/src/components/ui/Card.tsx +1 -1
  31. package/templates/base/mobile/src/components/ui/Input.tsx +1 -1
  32. package/templates/base/mobile/src/components/ui/Skeleton.tsx +1 -1
  33. package/templates/base/mobile/src/components/ui/Toast.tsx +106 -0
  34. package/templates/base/mobile/src/components/ui/{IconSymbol.tsx → icon-symbol.tsx} +7 -0
  35. package/templates/base/mobile/src/components/ui/{LoadingSpinner.tsx → loading-spinner.tsx} +1 -1
  36. package/templates/base/mobile/src/components/ui/{OnboardingLayout.tsx → onboarding-layout.tsx} +2 -2
  37. package/templates/base/mobile/src/components/ui/{PaywallLayout.tsx → paywall-layout.tsx} +3 -3
  38. package/templates/base/mobile/src/constants/Theme.ts +3 -3
  39. package/templates/base/mobile/src/context/{ThemeContext.tsx → theme-context.tsx} +2 -2
  40. package/templates/base/mobile/src/lib/auth-client.ts.ejs +18 -0
  41. package/templates/base/mobile/src/services/{sdkInitializer.ts.ejs → sdk-initializer.ts.ejs} +4 -4
  42. package/templates/base/web/.prettierignore +6 -0
  43. package/templates/base/web/.prettierrc +8 -0
  44. package/templates/base/web/eslint.config.mjs +31 -7
  45. package/templates/base/web/next.config.ts +50 -1
  46. package/templates/base/web/package.json.ejs +14 -2
  47. package/templates/base/web/src/app/globals.css +1 -1
  48. package/templates/base/web/src/app/layout.tsx.ejs +2 -0
  49. package/templates/base/web/src/components/auth/protected-route.tsx.ejs +32 -5
  50. package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +1 -1
  51. package/templates/base/web/src/hooks/use-device-session.ts.ejs +2 -2
  52. package/templates/base/web/src/lib/auth/actions.ts.ejs +438 -15
  53. package/templates/base/web/src/lib/auth/config.ts.ejs +13 -0
  54. package/templates/base/web/src/lib/auth/cookies.ts.ejs +61 -0
  55. package/templates/base/web/src/lib/device/actions.ts.ejs +2 -10
  56. package/templates/base/web/src/lib/device/types.ts +37 -0
  57. package/templates/base/web/src/proxy.ts.ejs +12 -2
  58. package/templates/base/web/src/store/{deviceSession.store.ts.ejs → device-session-store.ts.ejs} +1 -1
  59. package/templates/features/mobile/auth/app/(auth)/{_layout.tsx → _layout.tsx.ejs} +7 -0
  60. package/templates/features/mobile/auth/app/(auth)/{login.tsx → login.tsx.ejs} +9 -8
  61. package/templates/features/mobile/auth/app/(auth)/{register.tsx → register.tsx.ejs} +9 -8
  62. package/templates/features/mobile/auth/app/(auth)/two-factor-expired.tsx.ejs +88 -0
  63. package/templates/features/mobile/auth/app/(auth)/two-factor.tsx.ejs +259 -0
  64. package/templates/features/mobile/auth/app/(public)/_layout.tsx +9 -0
  65. package/templates/features/mobile/{tabs/app/(tabs)/index.tsx → auth/app/(public)/index.tsx.ejs} +76 -257
  66. package/templates/features/mobile/auth/app/(tabs)/settings/_layout.tsx.ejs +20 -0
  67. package/templates/features/mobile/auth/app/(tabs)/settings/index.tsx.ejs +137 -0
  68. package/templates/features/mobile/auth/app/(tabs)/settings/security/_layout.tsx.ejs +35 -0
  69. package/templates/features/mobile/auth/app/(tabs)/settings/security/index.tsx.ejs +147 -0
  70. package/templates/features/mobile/auth/app/(tabs)/settings/security/set-password.tsx.ejs +140 -0
  71. package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-manage.tsx.ejs +366 -0
  72. package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-setup.tsx.ejs +317 -0
  73. package/templates/features/mobile/auth/app/account.tsx.ejs +331 -0
  74. package/templates/features/mobile/auth/app/verify-email.tsx.ejs +315 -0
  75. package/templates/features/mobile/auth/components/auth/{LoginForm.tsx.ejs → login-form.tsx.ejs} +15 -3
  76. package/templates/features/mobile/auth/components/auth/protected-screen.tsx.ejs +56 -0
  77. package/templates/features/mobile/auth/components/auth/{RegisterForm.tsx.ejs → register-form.tsx.ejs} +5 -3
  78. package/templates/features/mobile/auth/hooks/{useAuth.ts.ejs → auth.ts.ejs} +255 -1
  79. package/templates/features/mobile/auth/hooks/two-factor-timeout.ts.ejs +56 -0
  80. package/templates/features/mobile/auth/services/{deviceSession.ts → device-session.ts} +2 -10
  81. package/templates/features/mobile/auth/store/{deviceSession.store.ts → device-session-store.ts} +14 -5
  82. package/templates/features/mobile/auth/types/device-session.ts +37 -0
  83. package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +3 -1
  84. package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +3 -1
  85. package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +6 -1
  86. package/templates/features/mobile/paywall/app/paywall.tsx +4 -4
  87. package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +0 -6
  88. package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx.ejs +60 -0
  89. package/templates/features/mobile/tabs/app/(tabs)/index.tsx.ejs +264 -0
  90. package/templates/features/web/auth/app/(app)/layout.tsx.ejs +8 -22
  91. package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +19 -3
  92. package/templates/features/web/auth/app/(auth)/login/two-factor/page.tsx.ejs +24 -0
  93. package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +117 -152
  94. package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/dashboard-client.tsx.ejs +41 -1
  95. package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/page.tsx.ejs +3 -1
  96. package/templates/features/web/auth/app/(protected)/layout.tsx.ejs +67 -0
  97. package/templates/features/web/auth/app/(protected)/settings/security/page.tsx.ejs +19 -0
  98. package/templates/features/web/auth/app/(protected)/settings/security/security-settings.tsx.ejs +73 -0
  99. package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/page.tsx.ejs +2 -6
  100. package/templates/features/web/auth/app/auth/callback/route.ts.ejs +5 -1
  101. package/templates/features/web/auth/app/auth/session-expired/route.ts.ejs +39 -0
  102. package/templates/features/web/auth/app/auth/two-factor-expired/route.ts.ejs +22 -0
  103. package/templates/features/web/auth/components/auth/login-form.tsx.ejs +18 -0
  104. package/templates/features/web/auth/components/auth/two-factor-verify.tsx.ejs +173 -0
  105. package/templates/features/web/auth/components/settings/set-password-form.tsx.ejs +123 -0
  106. package/templates/features/web/auth/components/settings/two-factor-manage.tsx.ejs +253 -0
  107. package/templates/features/web/auth/components/settings/two-factor-setup.tsx.ejs +249 -0
  108. package/templates/features/web/auth/components/ui/checkbox.tsx +32 -0
  109. package/templates/features/web/auth/components/ui/dialog.tsx +143 -0
  110. package/templates/features/web/auth/components/ui/input-otp.tsx +71 -0
  111. package/templates/integrations/mobile/adjust/store/{adjust.store.ts → adjust-store.ts} +3 -3
  112. package/templates/integrations/mobile/att/store/{att.store.ts → att-store.ts} +2 -2
  113. package/templates/integrations/mobile/revenuecat/store/{revenuecat.store.ts → revenuecat-store.ts} +1 -1
  114. package/templates/integrations/mobile/scate/store/{scate.store.ts → scate-store.ts} +1 -1
  115. package/templates/base/mobile/src/components/ui/index.ts +0 -6
  116. package/templates/base/mobile/src/store/index.ts.ejs +0 -18
  117. package/templates/base/web/src/lib/auth/index.ts.ejs +0 -40
  118. package/templates/features/mobile/auth/components/auth/index.ts +0 -2
  119. package/templates/features/mobile/auth/hooks/index.ts.ejs +0 -1
  120. /package/templates/base/mobile/src/services/{errorService.ts → error-service.ts} +0 -0
  121. /package/templates/base/mobile/src/store/{ui.store.ts → ui-store.ts} +0 -0
  122. /package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/sessions-client.tsx.ejs +0 -0
  123. /package/templates/integrations/mobile/adjust/services/{adjustService.ts.ejs → adjust-service.ts.ejs} +0 -0
  124. /package/templates/integrations/mobile/att/services/{attService.ts → att-service.ts} +0 -0
  125. /package/templates/integrations/mobile/att/services/{trackingPermissions.ts → tracking-permissions.ts} +0 -0
  126. /package/templates/integrations/mobile/revenuecat/services/{revenuecatService.ts.ejs → revenuecat-service.ts.ejs} +0 -0
  127. /package/templates/integrations/mobile/scate/services/{scateService.ts.ejs → scate-service.ts.ejs} +0 -0
@@ -1,4 +1,4 @@
1
- import { eq } from 'drizzle-orm';
1
+ import { eq, and } from 'drizzle-orm';
2
2
  import { db, schema } from "../../utils/db";
3
3
  import { ErrorFactory } from "../../utils/errors";
4
4
 
@@ -125,3 +125,30 @@ export const deleteUser = async (userId: string): Promise<void> => {
125
125
  });
126
126
  }
127
127
  };
128
+
129
+ /**
130
+ * Check if user has a credential account with password
131
+ * Returns true if user signed up with email/password, false for OAuth-only users
132
+ */
133
+ export const userHasPassword = async (userId: string): Promise<boolean> => {
134
+ try {
135
+ const [credentialAccount] = await db
136
+ .select({ password: schema.account.password })
137
+ .from(schema.account)
138
+ .where(
139
+ and(
140
+ eq(schema.account.userId, userId),
141
+ eq(schema.account.providerId, 'credential')
142
+ )
143
+ )
144
+ .limit(1);
145
+
146
+ return !!credentialAccount?.password;
147
+ } catch (error) {
148
+ throw ErrorFactory.databaseError({
149
+ operation: 'userHasPassword',
150
+ userId,
151
+ originalError: error instanceof Error ? error.message : String(error)
152
+ });
153
+ }
154
+ };
@@ -112,4 +112,29 @@ export const deleteUser = async (userId: string): Promise<void> => {
112
112
  originalError: error instanceof Error ? error.message : String(error)
113
113
  });
114
114
  }
115
+ };
116
+
117
+ /**
118
+ * Check if user has a credential account with password
119
+ * Returns true if user signed up with email/password, false for OAuth-only users
120
+ */
121
+ export const userHasPassword = async (userId: string): Promise<boolean> => {
122
+ try {
123
+ const credentialAccount = await db.account.findFirst({
124
+ where: {
125
+ userId,
126
+ providerId: 'credential',
127
+ password: { not: null },
128
+ },
129
+ select: { password: true },
130
+ });
131
+
132
+ return !!credentialAccount?.password;
133
+ } catch (error) {
134
+ throw ErrorFactory.databaseError({
135
+ operation: 'userHasPassword',
136
+ userId,
137
+ originalError: error instanceof Error ? error.message : String(error)
138
+ });
139
+ }
115
140
  };
@@ -24,6 +24,9 @@ export const user = pgTable('user', {
24
24
  image: text('image'),
25
25
  createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
26
26
  updatedAt: timestamp('updated_at', { mode: 'date' }).defaultNow().notNull().$onUpdate(() => new Date()),
27
+ <% if (features.authentication.twoFactor) { %>
28
+ twoFactorEnabled: boolean('two_factor_enabled').default(false),
29
+ <% } %>
27
30
  });
28
31
 
29
32
  // BetterAuth Session model
@@ -67,6 +70,16 @@ export const verification = pgTable('verification', {
67
70
  updatedAt: timestamp('updated_at', { mode: 'date' }).defaultNow().notNull().$onUpdate(() => new Date()),
68
71
  });
69
72
 
73
+ <% if (features.authentication.twoFactor) { %>
74
+ // BetterAuth TwoFactor model
75
+ export const twoFactor = pgTable('two_factor', {
76
+ id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
77
+ userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
78
+ secret: text('secret'),
79
+ backupCodes: text('backup_codes'),
80
+ });
81
+ <% } %>
82
+
70
83
  // ==================== App-Specific Tables ====================
71
84
 
72
85
  // Device Session model (anonymous sessions before authentication)
@@ -87,6 +100,9 @@ export const userRelations = relations(user, ({ many }) => ({
87
100
  sessions: many(session),
88
101
  accounts: many(account),
89
102
  deviceSessions: many(deviceSession),
103
+ <% if (features.authentication.twoFactor) { %>
104
+ twoFactor: many(twoFactor),
105
+ <% } %>
90
106
  }));
91
107
 
92
108
  export const sessionRelations = relations(session, ({ one }) => ({
@@ -103,6 +119,15 @@ export const accountRelations = relations(account, ({ one }) => ({
103
119
  }),
104
120
  }));
105
121
 
122
+ <% if (features.authentication.twoFactor) { %>
123
+ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
124
+ user: one(user, {
125
+ fields: [twoFactor.userId],
126
+ references: [user.id],
127
+ }),
128
+ }));
129
+ <% } %>
130
+
106
131
  export const deviceSessionRelations = relations(deviceSession, ({ one }) => ({
107
132
  migratedToUser: one(user, {
108
133
  fields: [deviceSession.migratedToUserId],
@@ -1,8 +1,8 @@
1
1
  import { betterAuth } from "better-auth";
2
2
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
3
3
  import { expo } from "@better-auth/expo";
4
- <% if (features.authentication.twoFactor) { %>
5
- import { twoFactor } from "better-auth/plugins";
4
+ <% if (features.authentication.twoFactor || features.authentication.emailVerification) { %>
5
+ import { <% if (features.authentication.twoFactor) { %>twoFactor, <% } %><% if (features.authentication.emailVerification) { %>emailOTP<% } %> } from "better-auth/plugins";
6
6
  <% } %>
7
7
  import { db } from "../utils/db";
8
8
  import * as schema from "../drizzle/schema";
@@ -18,12 +18,15 @@ export const auth = betterAuth({
18
18
  session: schema.session,
19
19
  account: schema.account,
20
20
  verification: schema.verification,
21
+ <% if (features.authentication.twoFactor) { %>
22
+ twoFactor: schema.twoFactor,
23
+ <% } %>
21
24
  },
22
25
  }),
23
26
  basePath: "/api/auth", // BetterAuth endpoints will be at /api/auth/*
24
27
  emailAndPassword: {
25
28
  enabled: true,
26
- requireEmailVerification: <%= features.authentication.emailVerification %>,
29
+ requireEmailVerification: false, // Allow sign-in, control access on frontend via ProtectedRoute
27
30
  <% if (features.authentication.passwordReset) { %>
28
31
  sendResetPassword: async ({ user, url }) => {
29
32
  await sendEmail({
@@ -39,21 +42,6 @@ export const auth = betterAuth({
39
42
  },
40
43
  <% } %>
41
44
  },
42
- <% if (features.authentication.emailVerification) { %>
43
- emailVerification: {
44
- sendVerificationEmail: async ({ user, url }) => {
45
- await sendEmail({
46
- to: user.email,
47
- subject: "Verify your email address",
48
- html: `
49
- <h1>Verify your email</h1>
50
- <p>Click the link below to verify your email address:</p>
51
- <a href="${url}">Verify Email</a>
52
- `,
53
- });
54
- },
55
- },
56
- <% } %>
57
45
  <% if (features.authentication.providers.google || features.authentication.providers.apple || features.authentication.providers.github) { %>
58
46
  socialProviders: {
59
47
  <% if (features.authentication.providers.google) { %>
@@ -81,6 +69,27 @@ export const auth = betterAuth({
81
69
  expo(), // Required for React Native/Expo OAuth deep link handling
82
70
  <% if (features.authentication.twoFactor) { %>
83
71
  twoFactor(),
72
+ <% } %>
73
+ <% if (features.authentication.emailVerification) { %>
74
+ emailOTP({
75
+ otpLength: 6,
76
+ expiresIn: 300, // 5 minutes
77
+ sendVerificationOnSignUp: true,
78
+ async sendVerificationOTP({ email, otp, type }) {
79
+ if (type === 'email-verification') {
80
+ await sendEmail({
81
+ to: email,
82
+ subject: "Your verification code",
83
+ html: `
84
+ <h1>Verify your email</h1>
85
+ <p>Your verification code is:</p>
86
+ <h2 style="font-size: 32px; letter-spacing: 8px; font-family: monospace;">${otp}</h2>
87
+ <p>This code expires in 5 minutes.</p>
88
+ `,
89
+ });
90
+ }
91
+ },
92
+ }),
84
93
  <% } %>
85
94
  ],
86
95
  session: {
@@ -1,8 +1,8 @@
1
1
  import { betterAuth } from "better-auth";
2
2
  import { prismaAdapter } from "better-auth/adapters/prisma";
3
3
  import { expo } from "@better-auth/expo";
4
- <% if (features.authentication.twoFactor) { %>
5
- import { twoFactor } from "better-auth/plugins";
4
+ <% if (features.authentication.twoFactor || features.authentication.emailVerification) { %>
5
+ import { <% if (features.authentication.twoFactor) { %>twoFactor, <% } %><% if (features.authentication.emailVerification) { %>emailOTP<% } %> } from "better-auth/plugins";
6
6
  <% } %>
7
7
  import { db } from "../utils/db";
8
8
  <% if (features.authentication.emailVerification || features.authentication.passwordReset) { %>
@@ -16,7 +16,7 @@ export const auth = betterAuth({
16
16
  basePath: "/api/auth", // BetterAuth endpoints will be at /api/auth/*
17
17
  emailAndPassword: {
18
18
  enabled: true,
19
- requireEmailVerification: <%= features.authentication.emailVerification %>,
19
+ requireEmailVerification: false, // Allow sign-in, control access on frontend via ProtectedRoute
20
20
  <% if (features.authentication.passwordReset) { %>
21
21
  sendResetPassword: async ({ user, url }) => {
22
22
  await sendEmail({
@@ -32,21 +32,6 @@ export const auth = betterAuth({
32
32
  },
33
33
  <% } %>
34
34
  },
35
- <% if (features.authentication.emailVerification) { %>
36
- emailVerification: {
37
- sendVerificationEmail: async ({ user, url }) => {
38
- await sendEmail({
39
- to: user.email,
40
- subject: "Verify your email address",
41
- html: `
42
- <h1>Verify your email</h1>
43
- <p>Click the link below to verify your email address:</p>
44
- <a href="${url}">Verify Email</a>
45
- `,
46
- });
47
- },
48
- },
49
- <% } %>
50
35
  <% if (features.authentication.providers.google || features.authentication.providers.apple || features.authentication.providers.github) { %>
51
36
  socialProviders: {
52
37
  <% if (features.authentication.providers.google) { %>
@@ -74,6 +59,27 @@ export const auth = betterAuth({
74
59
  expo(), // Required for React Native/Expo OAuth deep link handling
75
60
  <% if (features.authentication.twoFactor) { %>
76
61
  twoFactor(),
62
+ <% } %>
63
+ <% if (features.authentication.emailVerification) { %>
64
+ emailOTP({
65
+ otpLength: 6,
66
+ expiresIn: 300, // 5 minutes
67
+ sendVerificationOnSignUp: true,
68
+ async sendVerificationOTP({ email, otp, type }) {
69
+ if (type === 'email-verification') {
70
+ await sendEmail({
71
+ to: email,
72
+ subject: "Your verification code",
73
+ html: `
74
+ <h1>Verify your email</h1>
75
+ <p>Your verification code is:</p>
76
+ <h2 style="font-size: 32px; letter-spacing: 8px; font-family: monospace;">${otp}</h2>
77
+ <p>This code expires in 5 minutes.</p>
78
+ `,
79
+ });
80
+ }
81
+ },
82
+ }),
77
83
  <% } %>
78
84
  ],
79
85
  session: {
@@ -4,6 +4,12 @@
4
4
  "description": "Backend API for <%= projectName %>",
5
5
  "type": "module",
6
6
  "main": "controllers/rest-api/index.ts",
7
+ "overrides": {
8
+ "expo-network": "npm:empty-npm-package@1.0.0",
9
+ "expo-secure-store": "npm:empty-npm-package@1.0.0",
10
+ "@react-native-async-storage/async-storage": "npm:empty-npm-package@1.0.0",
11
+ "react-native": "npm:empty-npm-package@1.0.0"
12
+ },
7
13
  "scripts": {
8
14
  "dev:rest-api": "bun --watch ./controllers/rest-api/index.ts | pino-pretty --colorize",<% if (backend.eventQueue) { %>
9
15
  "dev:event-queue": "bun --watch ./controllers/event-queue/index.ts | pino-pretty --colorize",<% } %>
@@ -21,30 +27,30 @@
21
27
  "db:studio": "drizzle-kit studio"<% } %>
22
28
  },
23
29
  "dependencies": {
24
- "@fastify/cors": "^11.0.1",<% if (backend.orm === 'prisma') { %>
25
- "@prisma/adapter-pg": "^7.0.0",
26
- "@prisma/client": "^7.0.0",<% } %><% if (backend.orm === 'drizzle') { %>
27
- "drizzle-orm": "^0.44.0",
28
- "pg": "^8.13.0",<% } %>
29
- "@sinclair/typebox": "^0.34.33",
30
- "ajv": "^8.17.1",
31
- "better-auth": "^1.4.5",
32
- "@better-auth/expo": "^1.4.5",
33
- "dotenv": "^16.5.0",
34
- "fastify": "^5.3.3",
35
- "fastify-plugin": "^5.0.1",
36
- "ioredis": "^5.4.1",
37
- "pino-pretty": "^13.0.0"<% if (backend.eventQueue) { %>,
38
- "bullmq": "^5.40.3"<% } %><% if (features.authentication.emailVerification || features.authentication.passwordReset) { %>,
39
- "nodemailer": "^6.9.0"<% } %>
30
+ "@fastify/cors": "~11.0.1",<% if (backend.orm === 'prisma') { %>
31
+ "@prisma/adapter-pg": "~7.0.0",
32
+ "@prisma/client": "~7.0.0",<% } %><% if (backend.orm === 'drizzle') { %>
33
+ "drizzle-orm": "~0.44.0",
34
+ "pg": "~8.13.0",<% } %>
35
+ "@sinclair/typebox": "~0.34.33",
36
+ "ajv": "~8.17.1",
37
+ "better-auth": "~1.4.5",
38
+ "@better-auth/expo": "~1.4.5",
39
+ "dotenv": "~16.5.0",
40
+ "fastify": "~5.3.3",
41
+ "fastify-plugin": "~5.0.1",
42
+ "ioredis": "~5.4.1",
43
+ "pino-pretty": "~13.0.0"<% if (backend.eventQueue) { %>,
44
+ "bullmq": "~5.40.3"<% } %><% if (features.authentication.emailVerification || features.authentication.passwordReset) { %>,
45
+ "nodemailer": "~6.9.0"<% } %>
40
46
  },
41
47
  "devDependencies": {
42
- "@types/node": "^24.0.0",<% if (backend.orm === 'prisma') { %>
43
- "prisma": "^7.0.0",<% } %><% if (backend.orm === 'drizzle') { %>
44
- "drizzle-kit": "^0.30.0",
45
- "@types/pg": "^8.11.0",<% } %>
46
- "tsx": "^4.20.1",
47
- "typescript": "^5.8.3"<% if (features.authentication.emailVerification || features.authentication.passwordReset) { %>,
48
- "@types/nodemailer": "^6.4.0"<% } %>
48
+ "@types/node": "~24.0.0",<% if (backend.orm === 'prisma') { %>
49
+ "prisma": "~7.0.0",<% } %><% if (backend.orm === 'drizzle') { %>
50
+ "drizzle-kit": "~0.30.0",
51
+ "@types/pg": "~8.11.0",<% } %>
52
+ "tsx": "~4.20.1",
53
+ "typescript": "~5.8.3"<% if (features.authentication.emailVerification || features.authentication.passwordReset) { %>,
54
+ "@types/nodemailer": "~6.4.0"<% } %>
49
55
  }
50
56
  }
@@ -20,10 +20,16 @@ model User {
20
20
  image String?
21
21
  createdAt DateTime @default(now())
22
22
  updatedAt DateTime @updatedAt
23
+ <% if (features.authentication.twoFactor) { %>
24
+ twoFactorEnabled Boolean @default(false)
25
+ <% } %>
23
26
 
24
27
  // BetterAuth relations
25
28
  sessions Session[]
26
29
  accounts Account[]
30
+ <% if (features.authentication.twoFactor) { %>
31
+ twoFactor TwoFactor[]
32
+ <% } %>
27
33
 
28
34
  // Device session migration relation
29
35
  deviceSessions DeviceSession[]
@@ -100,3 +106,17 @@ model DeviceSession {
100
106
 
101
107
  @@map("device_session")
102
108
  }
109
+
110
+ <% if (features.authentication.twoFactor) { %>
111
+ // BetterAuth TwoFactor model
112
+ model TwoFactor {
113
+ id String @id @default(cuid())
114
+ userId String
115
+ secret String?
116
+ backupCodes String?
117
+
118
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
119
+
120
+ @@map("two_factor")
121
+ }
122
+ <% } %>
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { View, Text, StyleSheet } from 'react-native';
3
3
  import { router } from 'expo-router';
4
- import { Button } from '../src/components/ui';
4
+ import { Button } from '../src/components/ui/button';
5
5
 
6
6
  export default function NotFoundScreen() {
7
7
  const handleGoHome = () => {
@@ -1,17 +1,24 @@
1
- import { Stack, router } from 'expo-router';
2
- import { useEffect<% if (features.sessionManagement) { %>, useState<% } %> } from 'react';
1
+ import { Stack, router<% if (features.authentication.enabled) { %>, useSegments<% } %> } from 'expo-router';
2
+ import { useEffect, useRef<% if (!features.sessionManagement) { %>, useState<% } %> } from 'react';
3
+ import { AppState, AppStateStatus } from 'react-native';
3
4
  import Constants from 'expo-constants';
4
- import { ThemeProvider } from '@/context/ThemeContext';
5
+ import { ThemeProvider } from '@/context/theme-context';
6
+ import { Toast } from '@/components/ui/toast';
5
7
  <% if (features.onboarding.enabled) { %>import AsyncStorage from '@react-native-async-storage/async-storage';
6
- <% } %><% if (integrations.revenueCat.enabled || integrations.adjust.enabled || integrations.scate.enabled) { %>import { initializeSDKs } from '@/services/sdkInitializer';
7
- <% } %><% if (features.sessionManagement) { %>import { useSessionActions, useSession } from '../src/store/deviceSession.store';
8
+ <% } %><% if (integrations.revenueCat.enabled || integrations.adjust.enabled || integrations.scate.enabled) { %>import { initializeSDKs } from '@/services/sdk-initializer';
9
+ <% } %><% if (features.sessionManagement) { %>import { useSessionActions, useSession } from '../src/store/device-session-store';
10
+ <% } %><% if (features.authentication.enabled) { %>import { useAuth } from '../src/hooks/auth';
8
11
  <% } %>import { logger } from '../src/utils/logger';
9
12
 
10
13
  export default function RootLayout() {
11
- <% if (features.sessionManagement) { %> const { initializeSession } = useSessionActions();
14
+ <% if (features.sessionManagement) { %> const { initializeSession, isSessionValid } = useSessionActions();
12
15
  const { isSessionChecked } = useSession();
13
16
  <% } else { %> const [isReady, setIsReady] = useState(false);
17
+ <% } %><% if (features.authentication.enabled) { %> const { isAuthenticated, isPending: isAuthPending<% if (features.authentication.emailVerification) { %>, user<% } %> } = useAuth();
18
+ const segments = useSegments();
14
19
  <% } %>
20
+ const appState = useRef(AppState.currentState);
21
+
15
22
  // Get feature flags from app.json
16
23
  const onboardingEnabled = Constants.expoConfig?.extra?.features?.onboarding?.enabled ?? false;
17
24
 
@@ -44,7 +51,12 @@ export default function RootLayout() {
44
51
  logger.info('RootLayout: Initializing session');
45
52
  await initializeSession();
46
53
  <% } %>
47
- router.replace('/(tabs)');
54
+ <% if (features.authentication.enabled) { %> // Route based on authentication state
55
+ // Note: The actual routing will happen in the useEffect below once auth state is known
56
+ // For now, just mark initialization complete
57
+ logger.info('RootLayout: Waiting for auth state...');
58
+ <% } else { %> router.replace('/(tabs)');
59
+ <% } %>
48
60
  } catch (error) {
49
61
  logger.error('RootLayout: Failed to initialize app', { error });
50
62
  // Fallback to tabs on error
@@ -57,15 +69,88 @@ export default function RootLayout() {
57
69
  initializeApp();
58
70
  }, [<% if (features.sessionManagement) { %>isSessionChecked, initializeSession, <% } %>onboardingEnabled]);
59
71
 
72
+ <% if (features.sessionManagement) { %>
73
+ // Revalidate session when app comes to foreground
74
+ useEffect(() => {
75
+ const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
76
+ // App came to foreground from background/inactive
77
+ if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
78
+ logger.debug('RootLayout: App came to foreground, revalidating session');
79
+ isSessionValid();
80
+ }
81
+ appState.current = nextAppState;
82
+ });
83
+
84
+ return () => subscription.remove();
85
+ }, [isSessionValid]);
86
+ <% } %>
87
+ <% if (features.authentication.enabled) { %>
88
+ // Route based on authentication state<% if (features.authentication.emailVerification) { %> and email verification<% } %>
89
+ useEffect(() => {
90
+ // Wait for auth state to be determined
91
+ if (isAuthPending) {
92
+ return;
93
+ }
94
+
95
+ // Get current route group/screen
96
+ const currentRoute = segments[0];
97
+ const inAuthGroup = currentRoute === '(auth)';
98
+ const inPublicGroup = currentRoute === '(public)';
99
+ const inTabsGroup = currentRoute === '(tabs)';
100
+ <% if (features.onboarding.enabled) { %> const inOnboardingGroup = currentRoute === '(onboarding)';
101
+ <% } %><% if (features.authentication.emailVerification) { %> const onVerifyEmail = currentRoute === 'verify-email';
102
+ <% } %> const onAccountScreen = currentRoute === 'account';
103
+
104
+ // Don't interfere with<% if (features.onboarding.enabled) { %> onboarding,<% } %> auth modal, or account screen flows
105
+ if (<% if (features.onboarding.enabled) { %>inOnboardingGroup || <% } %>inAuthGroup || onAccountScreen) {
106
+ return;
107
+ }
108
+
109
+ logger.info('RootLayout: Auth state determined', {
110
+ isAuthenticated,
111
+ <% if (features.authentication.emailVerification) { %> emailVerified: user?.emailVerified,
112
+ <% } %> currentRoute
113
+ });
114
+
115
+ if (isAuthenticated) {
116
+ <% if (features.authentication.emailVerification) { %> // Check if email verification is required
117
+ if (user && user.emailVerified === false) {
118
+ // Unverified - navigate to full-screen verify-email
119
+ if (!onVerifyEmail) {
120
+ router.replace(`/verify-email?email=${encodeURIComponent(user.email)}`);
121
+ }
122
+ } else {
123
+ // Verified - navigate to tabs
124
+ if (!inTabsGroup) {
125
+ router.replace('/(tabs)');
126
+ }
127
+ }
128
+ <% } else { %> // Only navigate if not already in tabs
129
+ if (!inTabsGroup) {
130
+ router.replace('/(tabs)');
131
+ }
132
+ <% } %> } else {
133
+ // Not authenticated - navigate to public
134
+ if (!inPublicGroup) {
135
+ router.replace('/(public)');
136
+ }
137
+ }
138
+ }, [isAuthenticated, isAuthPending, <% if (features.authentication.emailVerification) { %>user, <% } %>segments]);
139
+ <% } %>
140
+
60
141
  return (
61
142
  <ThemeProvider>
62
143
  <Stack screenOptions={{ headerShown: false }}>
63
144
  <% if (features.onboarding.enabled) { %> <Stack.Screen name="(onboarding)" />
64
- <% } %><% if (features.authentication.enabled) { %> <Stack.Screen name="(auth)" />
65
- <% } %><% if (features.paywall) { %> <Stack.Screen name="paywall" options={{ presentation: 'modal' }} />
145
+ <% } %><% if (features.authentication.enabled) { %> <Stack.Screen name="(public)" />
146
+ <Stack.Screen name="(auth)" options={{ presentation: 'modal' }} />
147
+ <% if (features.authentication.emailVerification) { %> <Stack.Screen name="verify-email" />
148
+ <% } %><% } %><% if (features.paywall) { %> <Stack.Screen name="paywall" options={{ presentation: 'modal' }} />
66
149
  <% } %> <Stack.Screen name="(tabs)" />
67
- <Stack.Screen name="+not-found" />
150
+ <% if (features.authentication.enabled) { %> <Stack.Screen name="account" options={{ presentation: 'card' }} />
151
+ <% } %> <Stack.Screen name="+not-found" />
68
152
  </Stack>
153
+ <Toast />
69
154
  </ThemeProvider>
70
155
  );
71
156
  }
@@ -3,6 +3,9 @@
3
3
  "version": "1.0.0",
4
4
  "description": "React Native mobile app for <%= projectName %>",
5
5
  "main": "expo-router/entry",
6
+ "overrides": {
7
+ "react-dom": "npm:empty-npm-package@1.0.0"
8
+ },
6
9
  "scripts": {
7
10
  "start": "expo start",
8
11
  "android": "expo run:android",
@@ -12,8 +15,8 @@
12
15
  "type-check": "tsc --noEmit"
13
16
  },
14
17
  "dependencies": {
15
- "@expo/vector-icons": "^15.0.3",
16
- "@react-native-async-storage/async-storage": "2.2.0",
18
+ "@expo/vector-icons": "~15.0.3",
19
+ "@react-native-async-storage/async-storage": "~2.2.0",
17
20
  "expo": "~54.0.0",
18
21
  "expo-application": "~7.0.7",
19
22
  "expo-constants": "~18.0.10",
@@ -28,24 +31,29 @@
28
31
  "react-native": "0.81.5",
29
32
  "react-native-safe-area-context": "~5.6.0",
30
33
  "react-native-screens": "~4.16.0",
31
- "zustand": "^5.0.5"<% if (features.authentication.enabled) { %>,
32
- "axios": "^1.9.0",
34
+ "zustand": "~5.0.5"<% if (features.authentication.enabled) { %>,
35
+ "axios": "~1.9.0",
33
36
  "expo-secure-store": "~15.0.7",
34
37
  "expo-network": "~8.0.7",
35
- "better-auth": "^1.4.5",
36
- "@better-auth/expo": "^1.4.5"<% } %><% if (features.authentication.providers.google || features.authentication.providers.apple || features.authentication.providers.github) { %>,
38
+ "better-auth": "~1.4.5",
39
+ "@better-auth/expo": "~1.4.5"<% } %><% if (features.authentication.providers.google || features.authentication.providers.apple || features.authentication.providers.github) { %>,
37
40
  "expo-web-browser": "~15.0.7"<% } %><% if (features.authentication.providers.google) { %>,
38
- "@react-native-google-signin/google-signin": "^13.1.0"<% } %><% if (features.authentication.providers.apple) { %>,
41
+ "@react-native-google-signin/google-signin": "~13.1.0"<% } %><% if (features.authentication.providers.apple) { %>,
39
42
  "expo-apple-authentication": "~7.1.3"<% } %><% if (integrations.revenueCat.enabled) { %>,
40
- "react-native-purchases": "^9.1.0"<% } %><% if (integrations.adjust.enabled) { %>,
41
- "react-native-adjust": "^5.4.1"<% } %><% if (integrations.scate.enabled) { %>,
42
- "scatesdk-react": "^0.4.12"<% } %><% if (integrations.att.enabled) { %>,
43
- "expo-tracking-transparency": "~5.2.4"<% } %>
43
+ "react-native-purchases": "~9.1.0"<% } %><% if (integrations.adjust.enabled) { %>,
44
+ "react-native-adjust": "~5.4.1"<% } %><% if (integrations.scate.enabled) { %>,
45
+ "scatesdk-react": "~0.4.12"<% } %><% if (integrations.att.enabled) { %>,
46
+ "expo-tracking-transparency": "~5.2.4"<% } %><% if (features.authentication.twoFactor) { %>,
47
+ "react-native-svg": "~15.12.1",
48
+ "react-native-qrcode-svg": "~6.3.2",
49
+ "expo-clipboard": "~8.0.8",
50
+ "expo-file-system": "~19.0.21",
51
+ "expo-sharing": "~14.0.8"<% } %>
44
52
  },
45
53
  "devDependencies": {
46
- "@babel/core": "^7.25.2",
54
+ "@babel/core": "^7.26.0",
47
55
  "@types/react": "~19.1.10",
48
- "eslint": "^9.25.0",
56
+ "eslint": "~9.25.0",
49
57
  "eslint-config-expo": "~10.0.0",
50
58
  "typescript": "~5.9.2"
51
59
  },
@@ -9,12 +9,12 @@ import {
9
9
  PressableProps,
10
10
  Animated,
11
11
  } from 'react-native';
12
- import { useAppTheme, AppTheme } from '@/context/ThemeContext';
12
+ import { useAppTheme, AppTheme } from '@/context/theme-context';
13
13
 
14
14
  interface ButtonProps extends Omit<PressableProps, 'style'> {
15
15
  title: string;
16
16
  loading?: boolean;
17
- variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
17
+ variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
18
18
  size?: 'small' | 'medium' | 'large';
19
19
  fullWidth?: boolean;
20
20
  style?: ViewStyle;
@@ -79,7 +79,7 @@ export const Button: React.FC<ButtonProps> = ({
79
79
  {loading ? (
80
80
  <ActivityIndicator
81
81
  size="small"
82
- color={variant === 'primary' ? theme.colors.textInverse : theme.colors.primary}
82
+ color={variant === 'primary' || variant === 'destructive' ? theme.colors.textInverse : theme.colors.primary}
83
83
  />
84
84
  ) : (
85
85
  <Text style={[
@@ -109,6 +109,7 @@ const createStyles = (theme: AppTheme) => StyleSheet.create({
109
109
  secondary: { backgroundColor: theme.colors.backgroundSecondary },
110
110
  outline: { backgroundColor: 'transparent', borderColor: theme.colors.borderStrong },
111
111
  ghost: { backgroundColor: 'transparent' },
112
+ destructive: { backgroundColor: theme.colors.error, borderColor: theme.colors.error },
112
113
 
113
114
  small: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], minHeight: 36 },
114
115
  medium: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], minHeight: 48 },
@@ -122,6 +123,7 @@ const createStyles = (theme: AppTheme) => StyleSheet.create({
122
123
  secondaryText: { color: theme.colors.text },
123
124
  outlineText: { color: theme.colors.text },
124
125
  ghostText: { color: theme.colors.primary },
126
+ destructiveText: { color: theme.colors.textInverse },
125
127
 
126
128
  smallText: { fontSize: theme.typography.fontSize.sm },
127
129
  mediumText: { fontSize: theme.typography.fontSize.base },
@@ -1,6 +1,6 @@
1
1
  import React, { useRef, useMemo } from 'react';
2
2
  import { View, Text, StyleSheet, ViewStyle, Pressable, Animated } from 'react-native';
3
- import { useAppTheme, AppTheme } from '@/context/ThemeContext';
3
+ import { useAppTheme, AppTheme } from '@/context/theme-context';
4
4
 
5
5
  interface CardProps {
6
6
  children: React.ReactNode;
@@ -10,7 +10,7 @@ import {
10
10
  Pressable,
11
11
  Animated,
12
12
  } from 'react-native';
13
- import { useAppTheme, AppTheme } from '@/context/ThemeContext';
13
+ import { useAppTheme, AppTheme } from '@/context/theme-context';
14
14
 
15
15
  interface InputProps extends Omit<TextInputProps, 'style'> {
16
16
  label?: string;
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useRef, useMemo } from 'react';
2
2
  import { View, StyleSheet, Animated, ViewStyle } from 'react-native';
3
- import { useAppTheme, AppTheme } from '@/context/ThemeContext';
3
+ import { useAppTheme, AppTheme } from '@/context/theme-context';
4
4
 
5
5
  interface SkeletonProps {
6
6
  width?: number | string;