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 +4 -4
- data/CHANGELOG.md +20 -0
- data/lib/belt/cli/auth_command.rb +309 -0
- data/lib/belt/cli/destroy_command.rb +5 -1
- data/lib/belt/cli/generate_command.rb +5 -1
- data/lib/belt/route_dsl.rb +1 -1
- data/lib/belt/version.rb +1 -1
- data/lib/templates/generate/auth/cognito.tf.erb +73 -0
- data/lib/templates/generate/auth/cognito_outputs.tf.erb +19 -0
- data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +55 -0
- data/lib/templates/generate/auth/frontend/Login.jsx +81 -0
- data/lib/templates/generate/auth/frontend/ProtectedRoute.jsx +9 -0
- data/lib/templates/generate/auth/frontend/SignUp.jsx +58 -0
- data/lib/templates/generate/auth/frontend/apiClient.js +34 -0
- data/lib/templates/generate/auth/frontend/auth.js +80 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f706cafa79246d178c2aee6bfb05536bcb5f03111a24f13624e769ef7d9916a
|
|
4
|
+
data.tar.gz: a99d09c6d472d4826acf78dee6a4fded952c8745071b63c6ea4f54dbfd6b0aef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/belt/route_dsl.rb
CHANGED
|
@@ -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] || :
|
|
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
|
@@ -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,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.
|
|
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
|