belt 0.2.17 → 0.2.19

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e1813b72986b167b9449f438b18331cc2e9be46cffa58bd059c403f3c35703e
4
- data.tar.gz: 26c3aae3c049bbebff471d6a8a514114a26f8b2d0fdf4671cb154ae9a056798f
3
+ metadata.gz: 7f706cafa79246d178c2aee6bfb05536bcb5f03111a24f13624e769ef7d9916a
4
+ data.tar.gz: a99d09c6d472d4826acf78dee6a4fded952c8745071b63c6ea4f54dbfd6b0aef
5
5
  SHA512:
6
- metadata.gz: 23bbf9977c0cd765c7a1630e7a74ed480db6d62ee07c22e95e6a491a3a9c04d824b8463b0ef63ae4c4b343e13e96ff26c9edb5c86c8005fce7f0a167b6894c64
7
- data.tar.gz: e7f625fe93dcb125289530e2a3bdb9dee8c37938eeea41bc9154a31aca6b47675a6336e39c986f3a801719f4cf22052bad40968ff6e1b9a82936140f74432f4a
6
+ metadata.gz: 2009deb03f2f725c4e960e73abf7745b8a9fdd5899ea76178b3942f21c504fe76338912ba57f05b913db3a18494f635c508e4696a3c347e40ddcb973e4c61541
7
+ data.tar.gz: b84417ce5814e266715b5dc54b4a965261f8011fee552f9f594c82137751565ff29b20f17444b2be986abfd79c02059b935ae5aa97fcbdbbfdcc79ffafa0421b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.18
4
+
5
+ ### New generator: `belt generate auth`
6
+
7
+ Scaffolds Cognito user pool infrastructure for authentication. Creates the user pool, client, and wires `cognito_user_pool_arns` into the conveyor-belt resource automatically.
8
+
9
+ ```bash
10
+ belt g auth # Single pool: "main"
11
+ belt g auth web mobile # Multiple pools (e.g., web + mobile clients)
12
+ belt g auth web android ios # Three pools
13
+ belt destroy auth # Remove generated files
14
+ ```
15
+
16
+ What it generates:
17
+ - `infrastructure/modules/app/cognito.tf` — User pool + client with sensible defaults (password policy, email verification, deletion protection in prod)
18
+ - `infrastructure/modules/app/cognito_outputs.tf` — Pool ID, ARN, and client ID outputs
19
+ - Patches `main.tf` to pass `cognito_user_pool_arns` to the conveyor-belt resource
20
+
21
+ Multiple pools are supported out of the box — each gets a suffixed name (e.g., `myapp-prod-web`, `myapp-prod-mobile`) and its own set of outputs.
22
+
3
23
  ## 0.2.12
4
24
 
5
25
  ### Rename: `routes.tf.rb` → `routes.rb`, `schema.tf.rb` → `contracts.rb`
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+ require_relative 'app_detection'
6
+
7
+ module Belt
8
+ module CLI
9
+ class AuthCommand
10
+ TEMPLATE_DIR = File.expand_path('../../templates/generate/auth', __dir__)
11
+ MODULE_DIR = 'infrastructure/modules/app'
12
+
13
+ include AppDetection
14
+
15
+ def self.run(args)
16
+ if args.include?('--help') || args.include?('-h')
17
+ print_help
18
+ exit 0
19
+ end
20
+
21
+ force = args.delete('--force') || args.delete('-f')
22
+ signup = args.delete('--signup')
23
+ pools = parse_pools(args)
24
+
25
+ new(pools: pools, force: force, signup: signup).generate
26
+ end
27
+
28
+ def self.destroy(_args)
29
+ new(pools: [], force: false).remove
30
+ end
31
+
32
+ def self.print_help
33
+ puts <<~HELP
34
+ Generate Cognito user pool infrastructure for authentication.
35
+
36
+ Usage: belt generate auth [pool_names...] [options]
37
+
38
+ Options:
39
+ --signup Allow public user registration (generates frontend views)
40
+ --force, -f Overwrite existing cognito.tf (skip collision check)
41
+
42
+ Arguments:
43
+ pool_names Optional pool names for multiple user pools.
44
+ If omitted, generates a single pool named "main".
45
+
46
+ Examples:
47
+ belt g auth # Admin-only, single pool
48
+ belt g auth --signup # Public signup with frontend views
49
+ belt g auth web # Named pool: "web"
50
+ belt g auth web mobile # Two pools
51
+ belt g auth --force # Overwrite existing
52
+
53
+ What this generates:
54
+ infrastructure/modules/app/cognito.tf User pool + client resources
55
+ infrastructure/modules/app/cognito_outputs.tf Pool ID, ARN, and client ID outputs
56
+
57
+ With --signup (when frontend/ exists):
58
+ frontend/src/lib/auth.js Auth module (signIn, signUp, etc.)
59
+ frontend/src/lib/apiClient.js API client with Authorization header
60
+ frontend/src/pages/auth/Login.jsx Login page
61
+ frontend/src/pages/auth/SignUp.jsx Registration page
62
+ frontend/src/pages/auth/ConfirmEmail.jsx Email verification page
63
+ frontend/src/components/ProtectedRoute.jsx Route guard component
64
+
65
+ Without --signup (admin-only, default):
66
+ frontend/src/lib/auth.js Auth module (signIn only)
67
+ frontend/src/lib/apiClient.js API client with Authorization header
68
+ frontend/src/pages/auth/Login.jsx Login page
69
+ frontend/src/components/ProtectedRoute.jsx Route guard component
70
+
71
+ It also patches:
72
+ infrastructure/modules/app/main.tf Adds cognito_user_pool_arns to conveyor_belt
73
+
74
+ After running:
75
+ 1. Review the generated Cognito config in cognito.tf
76
+ 2. Add auth: :cognito to your routes namespace
77
+ 3. Run `belt deploy` to create the user pool
78
+ 4. Create your account (admin-only): aws cognito-idp admin-create-user ...
79
+ HELP
80
+ end
81
+
82
+ # Parse pool names from args. Default to ["main"] if none provided.
83
+ def self.parse_pools(args)
84
+ names = args.reject { |a| a.start_with?('-') }
85
+ names = ['main'] if names.empty?
86
+ names.map(&:downcase).map { |n| n.gsub(/[^a-z0-9_]/, '_') }
87
+ end
88
+
89
+ def initialize(pools:, force: false, signup: false)
90
+ @pool_names = pools
91
+ @force = force
92
+ @signup = signup
93
+ @app_name = detect_app_name
94
+ @pools = build_pool_metadata
95
+ end
96
+
97
+ def generate
98
+ check_collision! unless @force
99
+ ensure_module_dir!
100
+
101
+ write_cognito_tf
102
+ write_cognito_outputs_tf
103
+ patch_main_tf
104
+ generate_frontend_auth if frontend?
105
+
106
+ puts "\n✓ Auth generated!"
107
+ puts "\nNext steps:"
108
+ puts ' 1. Review infrastructure/modules/app/cognito.tf'
109
+ puts ' 2. Customize password policy, MFA, or Lambda triggers as needed'
110
+ puts ' 3. Run `belt apply <env>` to deploy'
111
+ puts ' 4. Create your account: aws cognito-idp admin-create-user ...' unless @signup
112
+ end
113
+
114
+ def remove
115
+ removed = []
116
+
117
+ cognito_tf = File.join(MODULE_DIR, 'cognito.tf')
118
+ cognito_outputs_tf = File.join(MODULE_DIR, 'cognito_outputs.tf')
119
+
120
+ if File.exist?(cognito_tf)
121
+ FileUtils.rm(cognito_tf)
122
+ removed << cognito_tf
123
+ puts " remove #{cognito_tf}"
124
+ end
125
+
126
+ if File.exist?(cognito_outputs_tf)
127
+ FileUtils.rm(cognito_outputs_tf)
128
+ removed << cognito_outputs_tf
129
+ puts " remove #{cognito_outputs_tf}"
130
+ end
131
+
132
+ unpatch_main_tf
133
+ removed << File.join(MODULE_DIR, 'main.tf') if @main_tf_patched
134
+
135
+ if removed.empty?
136
+ puts ' Nothing to remove — auth was not generated.'
137
+ else
138
+ puts "\n✓ Auth destroyed!"
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def build_pool_metadata
145
+ if @pool_names.length == 1 && @pool_names.first == 'main'
146
+ [{ name: 'main', suffix: '', label: '' }]
147
+ else
148
+ @pool_names.map do |name|
149
+ { name: name, suffix: "-#{name}", label: " (#{name})" }
150
+ end
151
+ end
152
+ end
153
+
154
+ def frontend?
155
+ Dir.exist?('frontend/src')
156
+ end
157
+
158
+ def generate_frontend_auth
159
+ frontend_template_dir = File.join(TEMPLATE_DIR, 'frontend')
160
+
161
+ # Generate auth lib files
162
+ lib_dir = 'frontend/src/lib'
163
+ FileUtils.mkdir_p(lib_dir)
164
+ copy_frontend_file(frontend_template_dir, 'auth.js', File.join(lib_dir, 'auth.js'))
165
+ copy_frontend_file(frontend_template_dir, 'apiClient.js', File.join(lib_dir, 'apiClient.js'))
166
+
167
+ pages_dir = 'frontend/src/pages/auth'
168
+ FileUtils.mkdir_p(pages_dir)
169
+ copy_frontend_file(frontend_template_dir, 'Login.jsx', File.join(pages_dir, 'Login.jsx'))
170
+ if @signup
171
+ # Generate auth pages
172
+ copy_frontend_file(frontend_template_dir, 'SignUp.jsx', File.join(pages_dir, 'SignUp.jsx'))
173
+ copy_frontend_file(frontend_template_dir, 'ConfirmEmail.jsx', File.join(pages_dir, 'ConfirmEmail.jsx'))
174
+ end
175
+
176
+ # Generate ProtectedRoute component
177
+ components_dir = 'frontend/src/components'
178
+ FileUtils.mkdir_p(components_dir)
179
+ copy_frontend_file(frontend_template_dir, 'ProtectedRoute.jsx',
180
+ File.join(components_dir, 'ProtectedRoute.jsx'))
181
+
182
+ install_cognito_sdk
183
+ end
184
+
185
+ def copy_frontend_file(template_dir, filename, dest)
186
+ src = File.join(template_dir, filename)
187
+ FileUtils.cp(src, dest)
188
+ puts " create #{dest}"
189
+ end
190
+
191
+ def install_cognito_sdk
192
+ puts "\n Installing @aws-sdk/client-cognito-identity-provider..."
193
+ success = system('npm', 'install', '@aws-sdk/client-cognito-identity-provider',
194
+ '--prefix', 'frontend', '--no-fund', '--no-audit', '--silent')
195
+ if success
196
+ puts ' ✓ npm dependency installed'
197
+ else
198
+ puts ' ⚠ npm install failed — run: cd frontend && npm install @aws-sdk/client-cognito-identity-provider'
199
+ end
200
+ end
201
+
202
+ def check_collision!
203
+ cognito_tf = File.join(MODULE_DIR, 'cognito.tf')
204
+ return unless File.exist?(cognito_tf)
205
+
206
+ puts "\n✗ Auth already exists at #{cognito_tf}"
207
+ puts "\nTo overwrite, run again with --force:"
208
+ puts " belt g auth #{@pool_names.join(' ')} --force"
209
+ exit 1
210
+ end
211
+
212
+ def ensure_module_dir!
213
+ FileUtils.mkdir_p(MODULE_DIR)
214
+ end
215
+
216
+ def write_cognito_tf
217
+ dest = File.join(MODULE_DIR, 'cognito.tf')
218
+ write_template('cognito.tf.erb', dest)
219
+ puts " create #{dest}"
220
+ end
221
+
222
+ def write_cognito_outputs_tf
223
+ dest = File.join(MODULE_DIR, 'cognito_outputs.tf')
224
+ write_template('cognito_outputs.tf.erb', dest)
225
+ puts " create #{dest}"
226
+ end
227
+
228
+ def patch_main_tf
229
+ main_tf = File.join(MODULE_DIR, 'main.tf')
230
+ return unless File.exist?(main_tf)
231
+
232
+ content = File.read(main_tf)
233
+
234
+ if content.include?('cognito_user_pool_arns')
235
+ puts " skip #{main_tf} (cognito_user_pool_arns already present)"
236
+ return
237
+ end
238
+
239
+ return unless content.match?(/^resource "conveyor_belt"/)
240
+
241
+ arns = @pools.map { |p| "aws_cognito_user_pool.#{p[:name]}.arn" }
242
+ arn_value = build_arn_value(arns)
243
+
244
+ insert_cognito_into_resource(content, main_tf, arn_value)
245
+ end
246
+
247
+ def build_arn_value(arns)
248
+ if arns.length == 1
249
+ "[#{arns.first}]"
250
+ else
251
+ "[\n #{arns.join(",\n ")}\n ]"
252
+ end
253
+ end
254
+
255
+ def insert_cognito_into_resource(content, main_tf, arn_value)
256
+ lines = content.lines
257
+ brace_depth = 0
258
+ insert_index = nil
259
+ in_resource = false
260
+
261
+ lines.each_with_index do |line, idx|
262
+ if line.match?(/^resource "conveyor_belt"/)
263
+ in_resource = true
264
+ brace_depth = 0
265
+ end
266
+
267
+ next unless in_resource
268
+
269
+ brace_depth += line.count('{') - line.count('}')
270
+
271
+ next unless brace_depth <= 0
272
+
273
+ insert_index = idx
274
+ break
275
+ end
276
+
277
+ return unless insert_index
278
+
279
+ cognito_line = "\n cognito_user_pool_arns = #{arn_value}\n"
280
+ lines.insert(insert_index, cognito_line)
281
+ File.write(main_tf, lines.join)
282
+ puts " update #{main_tf} (added cognito_user_pool_arns)"
283
+ end
284
+
285
+ def unpatch_main_tf
286
+ main_tf = File.join(MODULE_DIR, 'main.tf')
287
+ @main_tf_patched = false
288
+ return unless File.exist?(main_tf)
289
+
290
+ content = File.read(main_tf)
291
+ return unless content.include?('cognito_user_pool_arns')
292
+
293
+ # Remove the cognito_user_pool_arns line(s) — could be single or multi-line
294
+ updated = content.gsub(/\n\s*cognito_user_pool_arns\s*=\s*\[[^\]]*\]\n/, "\n")
295
+ return if updated == content
296
+
297
+ File.write(main_tf, updated)
298
+ puts " update #{main_tf} (removed cognito_user_pool_arns)"
299
+ @main_tf_patched = true
300
+ end
301
+
302
+ def write_template(template_name, dest_path)
303
+ template_path = File.join(TEMPLATE_DIR, template_name)
304
+ content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
305
+ File.write(dest_path, content)
306
+ end
307
+ end
308
+ end
309
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require_relative 'app_detection'
5
+ require_relative 'auth_command'
5
6
  require_relative 'generator_registry'
6
7
  require_relative 'tables_command'
7
8
  require_relative '../inflector'
@@ -9,7 +10,7 @@ require_relative '../inflector'
9
10
  module Belt
10
11
  module CLI
11
12
  class DestroyCommand
12
- GENERATORS = %w[scaffold resource model controller environment frontend views].freeze
13
+ GENERATORS = %w[scaffold resource model controller environment frontend views auth].freeze
13
14
 
14
15
  include AppDetection
15
16
 
@@ -51,6 +52,8 @@ module Belt
51
52
  new(generator, name, [], **flags).destroy
52
53
  when 'frontend'
53
54
  new(generator, nil, []).destroy
55
+ when 'auth'
56
+ Belt::CLI::AuthCommand.destroy(args)
54
57
  when 'views'
55
58
  name = args.shift
56
59
  if name.nil? || name.empty?
@@ -98,6 +101,7 @@ module Belt
98
101
  resource Alias for scaffold
99
102
  model Remove an ActiveItem model
100
103
  controller Remove a controller
104
+ auth Remove Cognito user pool infrastructure
101
105
  environment Remove a deployment environment and tear down infrastructure
102
106
  frontend Remove the frontend/ directory
103
107
  views Remove React pages for a resource
@@ -3,6 +3,7 @@
3
3
  require 'fileutils'
4
4
  require 'erb'
5
5
  require_relative 'app_detection'
6
+ require_relative 'auth_command'
6
7
  require_relative 'environment_command'
7
8
  require_relative 'frontend_command'
8
9
  require_relative 'tables_command'
@@ -14,7 +15,7 @@ module Belt
14
15
  module CLI
15
16
  class GenerateCommand
16
17
  TEMPLATE_DIR = File.expand_path('../../templates/generate', __dir__)
17
- GENERATORS = %w[scaffold resource model controller environment frontend views].freeze
18
+ GENERATORS = %w[scaffold resource model controller environment frontend views auth].freeze
18
19
 
19
20
  include AppDetection
20
21
 
@@ -126,6 +127,8 @@ module Belt
126
127
 
127
128
  return Belt::CLI::ViewsCommand.run(args) if generator == 'views'
128
129
 
130
+ return Belt::CLI::AuthCommand.run(args) if generator == 'auth'
131
+
129
132
  name = args.shift
130
133
  if name.nil? || name.empty?
131
134
  print_generator_help(generator)
@@ -192,6 +195,7 @@ module Belt
192
195
  scaffold Generate model, controller, routes, schema, and views (full REST resource)
193
196
  model Generate an ActiveItem model
194
197
  controller Generate a controller
198
+ auth Generate Cognito user pool infrastructure
195
199
  environment Create a new deployment environment
196
200
  frontend Scaffold a frontend app (react, vue, svelte)
197
201
  views Generate React pages for a resource
@@ -156,7 +156,7 @@ module Belt
156
156
  def initialize(name, options = {})
157
157
  @name = name.to_s
158
158
  @routes = []
159
- @default_auth = options[:auth] || :cognito
159
+ @default_auth = options[:auth] || :none
160
160
  @default_lambda = options[:lambda] || name
161
161
  @default_cors = options.fetch(:cors, true)
162
162
  @default_tables = Array(options[:tables] || [])
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.2.17'
4
+ VERSION = '0.2.19'
5
5
  end
@@ -0,0 +1,73 @@
1
+ # Cognito User Pool — authentication for <%= @app_name %>
2
+ # Generated by: belt generate auth
3
+
4
+ <% @pools.each do |pool| -%>
5
+ resource "aws_cognito_user_pool" "<%= pool[:name] %>" {
6
+ name = "${var.app_name}-${var.environment}<%= pool[:suffix] %>"
7
+
8
+ password_policy {
9
+ minimum_length = 8
10
+ require_lowercase = true
11
+ require_numbers = true
12
+ require_symbols = false
13
+ require_uppercase = true
14
+ }
15
+
16
+ # Admin-only signup — users cannot self-register.
17
+ # Remove this block to allow public sign-up.
18
+ <% unless @signup -%>
19
+ admin_create_user_config {
20
+ allow_admin_create_user_only = true
21
+ }
22
+
23
+ <% end -%>
24
+ auto_verified_attributes = ["email"]
25
+
26
+ account_recovery_setting {
27
+ recovery_mechanism {
28
+ name = "verified_email"
29
+ priority = 1
30
+ }
31
+ }
32
+
33
+ schema {
34
+ name = "email"
35
+ attribute_data_type = "String"
36
+ required = true
37
+ mutable = true
38
+
39
+ string_attribute_constraints {
40
+ min_length = 1
41
+ max_length = 256
42
+ }
43
+ }
44
+
45
+ deletion_protection = var.environment == "prod" ? "ACTIVE" : "INACTIVE"
46
+ }
47
+
48
+ resource "aws_cognito_user_pool_client" "<%= pool[:name] %>" {
49
+ name = "${var.app_name}-${var.environment}<%= pool[:suffix] %>-client"
50
+ user_pool_id = aws_cognito_user_pool.<%= pool[:name] %>.id
51
+
52
+ explicit_auth_flows = [
53
+ "ALLOW_USER_SRP_AUTH",
54
+ <% if @signup -%>
55
+ "ALLOW_USER_PASSWORD_AUTH",
56
+ <% end -%>
57
+ "ALLOW_REFRESH_TOKEN_AUTH"
58
+ ]
59
+
60
+ prevent_user_existence_errors = "ENABLED"
61
+
62
+ token_validity_units {
63
+ access_token = "hours"
64
+ id_token = "hours"
65
+ refresh_token = "days"
66
+ }
67
+
68
+ access_token_validity = 1
69
+ id_token_validity = 1
70
+ refresh_token_validity = 30
71
+ }
72
+
73
+ <% end -%>
@@ -0,0 +1,19 @@
1
+ # Cognito outputs — generated by: belt generate auth
2
+
3
+ <% @pools.each do |pool| -%>
4
+ output "cognito_user_pool_id<%= pool[:suffix] %>" {
5
+ description = "Cognito User Pool ID<%= pool[:label] %>"
6
+ value = aws_cognito_user_pool.<%= pool[:name] %>.id
7
+ }
8
+
9
+ output "cognito_user_pool_arn<%= pool[:suffix] %>" {
10
+ description = "Cognito User Pool ARN<%= pool[:label] %>"
11
+ value = aws_cognito_user_pool.<%= pool[:name] %>.arn
12
+ }
13
+
14
+ output "cognito_client_id<%= pool[:suffix] %>" {
15
+ description = "Cognito User Pool Client ID<%= pool[:label] %>"
16
+ value = aws_cognito_user_pool_client.<%= pool[:name] %>.id
17
+ }
18
+
19
+ <% end -%>
@@ -0,0 +1,55 @@
1
+ import { useState } from 'react'
2
+ import { confirmSignUp } from '../lib/auth'
3
+
4
+ export default function ConfirmEmail() {
5
+ const params = new URLSearchParams(window.location.search)
6
+ const [email, setEmail] = useState(params.get('email') || '')
7
+ const [code, setCode] = useState('')
8
+ const [error, setError] = useState('')
9
+ const [loading, setLoading] = useState(false)
10
+ const [confirmed, setConfirmed] = useState(false)
11
+
12
+ async function handleSubmit(e) {
13
+ e.preventDefault()
14
+ setError('')
15
+ setLoading(true)
16
+
17
+ try {
18
+ await confirmSignUp(email, code)
19
+ setConfirmed(true)
20
+ } catch (err) {
21
+ setError(err.message || 'Confirmation failed')
22
+ } finally {
23
+ setLoading(false)
24
+ }
25
+ }
26
+
27
+ if (confirmed) {
28
+ return (
29
+ <div className="auth-page">
30
+ <div className="auth-form">
31
+ <h2>Email Verified! ✓</h2>
32
+ <p>Your account is ready.</p>
33
+ <a href="/login" className="auth-btn">Sign In</a>
34
+ </div>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ return (
40
+ <div className="auth-page">
41
+ <form className="auth-form" onSubmit={handleSubmit}>
42
+ <h2>Verify Email</h2>
43
+ <p>Enter the code sent to your email.</p>
44
+ {error && <div className="auth-error">{error}</div>}
45
+ <input type="email" placeholder="Email" value={email}
46
+ onChange={e => setEmail(e.target.value)} required />
47
+ <input type="text" placeholder="Verification code" value={code}
48
+ onChange={e => setCode(e.target.value)} required autoFocus />
49
+ <button type="submit" disabled={loading}>
50
+ {loading ? 'Verifying...' : 'Verify'}
51
+ </button>
52
+ </form>
53
+ </div>
54
+ )
55
+ }
@@ -0,0 +1,81 @@
1
+ import { useState } from 'react'
2
+ import { signIn, completeNewPassword } from '../lib/auth'
3
+
4
+ export default function Login({ onLogin }) {
5
+ const [email, setEmail] = useState('')
6
+ const [password, setPassword] = useState('')
7
+ const [newPassword, setNewPassword] = useState('')
8
+ const [error, setError] = useState('')
9
+ const [challenge, setChallenge] = useState(null)
10
+ const [loading, setLoading] = useState(false)
11
+
12
+ async function handleSubmit(e) {
13
+ e.preventDefault()
14
+ setError('')
15
+ setLoading(true)
16
+
17
+ try {
18
+ const result = await signIn(email, password)
19
+ if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
20
+ setChallenge(result)
21
+ } else {
22
+ onLogin()
23
+ }
24
+ } catch (err) {
25
+ setError(err.message || 'Sign in failed')
26
+ } finally {
27
+ setLoading(false)
28
+ }
29
+ }
30
+
31
+ async function handleNewPassword(e) {
32
+ e.preventDefault()
33
+ setError('')
34
+ setLoading(true)
35
+
36
+ try {
37
+ await completeNewPassword(email, newPassword, challenge.session)
38
+ onLogin()
39
+ } catch (err) {
40
+ setError(err.message || 'Password change failed')
41
+ } finally {
42
+ setLoading(false)
43
+ }
44
+ }
45
+
46
+ if (challenge) {
47
+ return (
48
+ <div className="auth-page">
49
+ <form className="auth-form" onSubmit={handleNewPassword}>
50
+ <h2>Set New Password</h2>
51
+ <p>Please set a permanent password for your account.</p>
52
+ {error && <div className="auth-error">{error}</div>}
53
+ <input type="password" placeholder="New password" value={newPassword}
54
+ onChange={e => setNewPassword(e.target.value)} required autoFocus />
55
+ <button type="submit" disabled={loading}>
56
+ {loading ? 'Saving...' : 'Set Password'}
57
+ </button>
58
+ </form>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ return (
64
+ <div className="auth-page">
65
+ <form className="auth-form" onSubmit={handleSubmit}>
66
+ <h2>Sign In</h2>
67
+ {error && <div className="auth-error">{error}</div>}
68
+ <input type="email" placeholder="Email" value={email}
69
+ onChange={e => setEmail(e.target.value)} required autoFocus />
70
+ <input type="password" placeholder="Password" value={password}
71
+ onChange={e => setPassword(e.target.value)} required />
72
+ <button type="submit" disabled={loading}>
73
+ {loading ? 'Signing in...' : 'Sign In'}
74
+ </button>
75
+ <p className="auth-link">
76
+ Don't have an account? <a href="/signup">Sign up</a>
77
+ </p>
78
+ </form>
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,9 @@
1
+ import { isAuthenticated } from '../lib/auth'
2
+ import { Navigate } from 'react-router-dom'
3
+
4
+ export default function ProtectedRoute({ children }) {
5
+ if (!isAuthenticated()) {
6
+ return <Navigate to="/login" replace />
7
+ }
8
+ return children
9
+ }
@@ -0,0 +1,58 @@
1
+ import { useState } from 'react'
2
+ import { signUp } from '../lib/auth'
3
+
4
+ export default function SignUp() {
5
+ const [email, setEmail] = useState('')
6
+ const [password, setPassword] = useState('')
7
+ const [error, setError] = useState('')
8
+ const [loading, setLoading] = useState(false)
9
+ const [needsConfirmation, setNeedsConfirmation] = useState(false)
10
+
11
+ async function handleSubmit(e) {
12
+ e.preventDefault()
13
+ setError('')
14
+ setLoading(true)
15
+
16
+ try {
17
+ const result = await signUp(email, password)
18
+ if (result.needsConfirmation) setNeedsConfirmation(true)
19
+ } catch (err) {
20
+ setError(err.message || 'Sign up failed')
21
+ } finally {
22
+ setLoading(false)
23
+ }
24
+ }
25
+
26
+ if (needsConfirmation) {
27
+ return (
28
+ <div className="auth-page">
29
+ <div className="auth-form">
30
+ <h2>Check Your Email</h2>
31
+ <p>We sent a verification code to <strong>{email}</strong>.</p>
32
+ <a href={`/confirm?email=${encodeURIComponent(email)}`} className="auth-btn">
33
+ Enter Code
34
+ </a>
35
+ </div>
36
+ </div>
37
+ )
38
+ }
39
+
40
+ return (
41
+ <div className="auth-page">
42
+ <form className="auth-form" onSubmit={handleSubmit}>
43
+ <h2>Create Account</h2>
44
+ {error && <div className="auth-error">{error}</div>}
45
+ <input type="email" placeholder="Email" value={email}
46
+ onChange={e => setEmail(e.target.value)} required autoFocus />
47
+ <input type="password" placeholder="Password (8+ characters)" value={password}
48
+ onChange={e => setPassword(e.target.value)} required minLength={8} />
49
+ <button type="submit" disabled={loading}>
50
+ {loading ? 'Creating...' : 'Create Account'}
51
+ </button>
52
+ <p className="auth-link">
53
+ Already have an account? <a href="/login">Sign in</a>
54
+ </p>
55
+ </form>
56
+ </div>
57
+ )
58
+ }
@@ -0,0 +1,34 @@
1
+ import { getToken } from './auth'
2
+
3
+ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
4
+
5
+ export async function apiClient(path, options = {}) {
6
+ const { method = 'GET', body, headers = {} } = options
7
+
8
+ const token = getToken()
9
+ const config = {
10
+ method,
11
+ headers: {
12
+ Accept: 'application/json',
13
+ ...(token ? { Authorization: token } : {}),
14
+ ...headers
15
+ }
16
+ }
17
+
18
+ if (body !== undefined && body !== null) {
19
+ config.headers['Content-Type'] = 'application/json'
20
+ config.body = JSON.stringify(body)
21
+ }
22
+
23
+ const response = await fetch(`${API_URL}${path}`, config)
24
+ const data = await response.json()
25
+
26
+ if (response.status === 401) {
27
+ window.location.href = '/login'
28
+ throw new Error('Unauthorized')
29
+ }
30
+
31
+ if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`)
32
+
33
+ return data
34
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ CognitoIdentityProviderClient,
3
+ InitiateAuthCommand,
4
+ RespondToAuthChallengeCommand,
5
+ SignUpCommand,
6
+ ConfirmSignUpCommand
7
+ } from '@aws-sdk/client-cognito-identity-provider'
8
+
9
+ const REGION = import.meta.env.VITE_AWS_REGION
10
+ const CLIENT_ID = import.meta.env.VITE_COGNITO_CLIENT_ID
11
+
12
+ const client = new CognitoIdentityProviderClient({ region: REGION })
13
+
14
+ let idToken = null
15
+ let refreshToken = null
16
+
17
+ export function getToken() { return idToken }
18
+ export function isAuthenticated() { return idToken !== null }
19
+
20
+ export async function signIn(username, password) {
21
+ const response = await client.send(new InitiateAuthCommand({
22
+ AuthFlow: 'USER_PASSWORD_AUTH',
23
+ ClientId: CLIENT_ID,
24
+ AuthParameters: { USERNAME: username, PASSWORD: password }
25
+ }))
26
+
27
+ if (response.ChallengeName === 'NEW_PASSWORD_REQUIRED') {
28
+ return { challenge: 'NEW_PASSWORD_REQUIRED', session: response.Session }
29
+ }
30
+
31
+ setTokens(response.AuthenticationResult)
32
+ return { success: true }
33
+ }
34
+
35
+ export async function signUp(email, password) {
36
+ await client.send(new SignUpCommand({
37
+ ClientId: CLIENT_ID,
38
+ Username: email,
39
+ Password: password
40
+ }))
41
+ return { needsConfirmation: true }
42
+ }
43
+
44
+ export async function confirmSignUp(email, code) {
45
+ await client.send(new ConfirmSignUpCommand({
46
+ ClientId: CLIENT_ID,
47
+ Username: email,
48
+ ConfirmationCode: code
49
+ }))
50
+ return { success: true }
51
+ }
52
+
53
+ export async function completeNewPassword(username, newPassword, session) {
54
+ const response = await client.send(new RespondToAuthChallengeCommand({
55
+ ChallengeName: 'NEW_PASSWORD_REQUIRED',
56
+ ClientId: CLIENT_ID,
57
+ Session: session,
58
+ ChallengeResponses: { USERNAME: username, NEW_PASSWORD: newPassword }
59
+ }))
60
+ setTokens(response.AuthenticationResult)
61
+ return { success: true }
62
+ }
63
+
64
+ export function signOut() {
65
+ idToken = null
66
+ refreshToken = null
67
+ localStorage.removeItem('idToken')
68
+ localStorage.removeItem('refreshToken')
69
+ }
70
+
71
+ function setTokens(authResult) {
72
+ idToken = authResult.IdToken
73
+ refreshToken = authResult.RefreshToken
74
+ localStorage.setItem('idToken', idToken)
75
+ if (refreshToken) localStorage.setItem('refreshToken', refreshToken)
76
+ }
77
+
78
+ // Restore on page load
79
+ const stored = localStorage.getItem('idToken')
80
+ if (stored) idToken = stored
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.17
4
+ version: 0.2.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -76,6 +76,7 @@ files:
76
76
  - lib/belt/assets/welcome.css
77
77
  - lib/belt/cli.rb
78
78
  - lib/belt/cli/app_detection.rb
79
+ - lib/belt/cli/auth_command.rb
79
80
  - lib/belt/cli/backup_config.rb
80
81
  - lib/belt/cli/backup_runner.rb
81
82
  - lib/belt/cli/bucket_security.rb
@@ -141,6 +142,14 @@ files:
141
142
  - lib/templates/frontend/react/src/pages/Home.jsx.erb
142
143
  - lib/templates/frontend/react/vite.config.js
143
144
  - lib/templates/frontend_infra/frontend.tf.erb
145
+ - lib/templates/generate/auth/cognito.tf.erb
146
+ - lib/templates/generate/auth/cognito_outputs.tf.erb
147
+ - lib/templates/generate/auth/frontend/ConfirmEmail.jsx
148
+ - lib/templates/generate/auth/frontend/Login.jsx
149
+ - lib/templates/generate/auth/frontend/ProtectedRoute.jsx
150
+ - lib/templates/generate/auth/frontend/SignUp.jsx
151
+ - lib/templates/generate/auth/frontend/apiClient.js
152
+ - lib/templates/generate/auth/frontend/auth.js
144
153
  - lib/templates/generate/controller.rb.erb
145
154
  - lib/templates/generate/model.rb.erb
146
155
  - lib/templates/module/dns.tf.erb