belt 0.2.18 → 0.2.20
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.
Potentially problematic release.
This version of belt might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/lib/belt/cli/auth_command.rb +150 -14
- data/lib/belt/cli/console_command.rb +17 -2
- data/lib/belt/cli/deploy_command.rb +9 -0
- data/lib/belt/cli/destroy_command.rb +3 -1
- data/lib/belt/cli/doctor_command.rb +166 -22
- data/lib/belt/cli/generate_command.rb +24 -31
- data/lib/belt/cli/index_command.rb +192 -0
- data/lib/belt/cli/lambda_config_command.rb +2 -1
- data/lib/belt/cli/routes_command/route_inference.rb +4 -3
- data/lib/belt/cli/routes_command.rb +0 -19
- data/lib/belt/cli/views_command.rb +3 -9
- data/lib/belt/cli.rb +1 -0
- data/lib/belt/inflector.rb +8 -0
- data/lib/belt/route_dsl.rb +3 -2
- data/lib/belt/version.rb +1 -1
- data/lib/templates/generate/auth/cognito.tf.erb +11 -0
- data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +55 -0
- data/lib/templates/generate/auth/frontend/Login.jsx +79 -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.css +157 -0
- data/lib/templates/generate/auth/frontend/auth.js +80 -0
- data/lib/templates/generate/model.rb.erb +4 -6
- data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
- metadata +37 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tables_command'
|
|
4
|
+
require_relative '../inflector'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class IndexCommand
|
|
9
|
+
MODULE_DIR = 'infrastructure/modules/app'
|
|
10
|
+
DYNAMODB_TF = File.join(MODULE_DIR, 'dynamodb.tf')
|
|
11
|
+
|
|
12
|
+
def self.run(args)
|
|
13
|
+
action = args.shift
|
|
14
|
+
|
|
15
|
+
case action
|
|
16
|
+
when 'add', nil
|
|
17
|
+
add(args)
|
|
18
|
+
when 'remove', 'rm'
|
|
19
|
+
remove(args)
|
|
20
|
+
when '--help', '-h'
|
|
21
|
+
puts usage
|
|
22
|
+
else
|
|
23
|
+
# Treat first arg as table name if no subcommand
|
|
24
|
+
add([action] + args)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.usage
|
|
29
|
+
<<~USAGE
|
|
30
|
+
Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]
|
|
31
|
+
belt destroy index <table> <IndexName>
|
|
32
|
+
|
|
33
|
+
Add or remove a Global Secondary Index (GSI) from dynamodb.tf.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
belt generate index messages ConversationIndex --partition-key conversation_id
|
|
37
|
+
belt generate index messages RecentByUserIndex --partition-key user_id --sort-key created_at
|
|
38
|
+
belt destroy index messages ConversationIndex
|
|
39
|
+
|
|
40
|
+
Note: After modifying indexes, run `belt deploy` to apply changes to AWS.
|
|
41
|
+
Adding a GSI to an existing table takes ~5-10 minutes (AWS limitation).
|
|
42
|
+
USAGE
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.add(args)
|
|
46
|
+
table, index_name, partition_key, sort_key = parse_add_args(args)
|
|
47
|
+
new(table, index_name, partition_key: partition_key, sort_key: sort_key).add
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.remove(args)
|
|
51
|
+
table = args.shift
|
|
52
|
+
index_name = args.shift
|
|
53
|
+
|
|
54
|
+
if table.nil? || index_name.nil?
|
|
55
|
+
abort "Usage: belt destroy index <table> <IndexName>\n\n" \
|
|
56
|
+
'Example: belt destroy index messages ConversationIndex'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
new(table, index_name).remove
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.parse_add_args(args)
|
|
63
|
+
table = args.shift
|
|
64
|
+
index_name = args.shift
|
|
65
|
+
partition_key = nil
|
|
66
|
+
sort_key = nil
|
|
67
|
+
|
|
68
|
+
i = 0
|
|
69
|
+
while i < args.length
|
|
70
|
+
case args[i]
|
|
71
|
+
when '--partition-key', '-p'
|
|
72
|
+
i += 1
|
|
73
|
+
partition_key = args[i]
|
|
74
|
+
when '--sort-key', '-s'
|
|
75
|
+
i += 1
|
|
76
|
+
sort_key = args[i]
|
|
77
|
+
end
|
|
78
|
+
i += 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if table.nil? || index_name.nil? || partition_key.nil?
|
|
82
|
+
abort "Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]\n\n" \
|
|
83
|
+
'Example: belt generate index messages ConversationIndex --partition-key conversation_id'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
[table, index_name, partition_key, sort_key]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize(table, index_name, partition_key: nil, sort_key: nil)
|
|
90
|
+
@table = table
|
|
91
|
+
@index_name = index_name
|
|
92
|
+
@partition_key = partition_key
|
|
93
|
+
@sort_key = sort_key
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def add
|
|
97
|
+
validate_tf_exists!
|
|
98
|
+
|
|
99
|
+
content = File.read(DYNAMODB_TF)
|
|
100
|
+
table_resource = "aws_dynamodb_table\" \"#{@table}\""
|
|
101
|
+
|
|
102
|
+
unless content.include?(table_resource)
|
|
103
|
+
abort "Error: Table '#{@table}' not found in #{DYNAMODB_TF}.\n" \
|
|
104
|
+
'Run `belt setup tables` first to generate the table.'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if content.include?("name = \"#{@index_name}\"")
|
|
108
|
+
puts " skip #{@index_name} (already exists on #{@table})"
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
dynamo_pk = Belt::Inflector.camelize_lower(@partition_key)
|
|
113
|
+
dynamo_sk = @sort_key ? Belt::Inflector.camelize_lower(@sort_key) : nil
|
|
114
|
+
|
|
115
|
+
# Build the GSI block
|
|
116
|
+
gsi_block = build_gsi_block(dynamo_pk, dynamo_sk)
|
|
117
|
+
|
|
118
|
+
# Build attribute blocks for new keys
|
|
119
|
+
attr_blocks = build_attribute_blocks(content, dynamo_pk, dynamo_sk)
|
|
120
|
+
|
|
121
|
+
# Insert into the table resource
|
|
122
|
+
insert_gsi(content, gsi_block, attr_blocks)
|
|
123
|
+
|
|
124
|
+
puts " create #{@index_name} on #{@table} (partition: #{dynamo_pk}#{", sort: #{dynamo_sk}" if dynamo_sk})"
|
|
125
|
+
puts "\n Run `belt deploy` to apply. Adding a GSI to an existing table takes ~5-10 min."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def remove
|
|
129
|
+
validate_tf_exists!
|
|
130
|
+
|
|
131
|
+
content = File.read(DYNAMODB_TF)
|
|
132
|
+
|
|
133
|
+
unless content.include?("name = \"#{@index_name}\"")
|
|
134
|
+
abort "Error: Index '#{@index_name}' not found in #{DYNAMODB_TF}."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Remove the GSI block
|
|
138
|
+
content.sub!(/\n\s*global_secondary_index \{\n\s*name\s*=\s*"#{Regexp.escape(@index_name)}".*?\n\s*\}/m, '')
|
|
139
|
+
|
|
140
|
+
File.write(DYNAMODB_TF, content)
|
|
141
|
+
puts " remove #{@index_name} from #{@table}"
|
|
142
|
+
puts "\n Run `belt deploy` to apply."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def validate_tf_exists!
|
|
148
|
+
return if File.exist?(DYNAMODB_TF)
|
|
149
|
+
|
|
150
|
+
abort "Error: #{DYNAMODB_TF} not found.\nRun `belt setup tables` first."
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_gsi_block(dynamo_pk, dynamo_sk)
|
|
154
|
+
lines = []
|
|
155
|
+
lines << ' global_secondary_index {'
|
|
156
|
+
lines << " name = \"#{@index_name}\""
|
|
157
|
+
lines << " hash_key = \"#{dynamo_pk}\""
|
|
158
|
+
lines << " range_key = \"#{dynamo_sk}\"" if dynamo_sk
|
|
159
|
+
lines << ' projection_type = "ALL"'
|
|
160
|
+
lines << ' }'
|
|
161
|
+
lines.join("\n")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_attribute_blocks(content, dynamo_pk, dynamo_sk)
|
|
165
|
+
blocks = []
|
|
166
|
+
[dynamo_pk, dynamo_sk].compact.each do |key|
|
|
167
|
+
next if content.include?("name = \"#{key}\"")
|
|
168
|
+
|
|
169
|
+
blocks << "\n attribute {\n name = \"#{key}\"\n type = \"S\"\n }"
|
|
170
|
+
end
|
|
171
|
+
blocks.join
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def insert_gsi(content, gsi_block, attr_blocks)
|
|
175
|
+
# Find the table's resource block and insert before point_in_time_recovery
|
|
176
|
+
table_pattern = /resource "aws_dynamodb_table" "#{Regexp.escape(@table)}" \{.*?point_in_time_recovery/m
|
|
177
|
+
|
|
178
|
+
content.sub!(table_pattern) do |match|
|
|
179
|
+
# Insert attributes after last existing attribute block
|
|
180
|
+
unless attr_blocks.empty?
|
|
181
|
+
match.sub!(/( attribute \{.*?\n \})(?!.*attribute)/m) { |attr_match| "#{attr_match}#{attr_blocks}" }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Insert GSI before point_in_time_recovery
|
|
185
|
+
match.sub('point_in_time_recovery', "#{gsi_block}\n\n point_in_time_recovery")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
File.write(DYNAMODB_TF, content)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -34,7 +34,7 @@ module Belt
|
|
|
34
34
|
SUPPORTED_KEYS = %w[
|
|
35
35
|
timeout memory_size env_vars env_keys
|
|
36
36
|
s3_buckets dynamodb_tables sns_triggers sqs_triggers
|
|
37
|
-
reserved_concurrency ephemeral_storage
|
|
37
|
+
reserved_concurrency ephemeral_storage iam_policy_arns
|
|
38
38
|
].freeze
|
|
39
39
|
|
|
40
40
|
def self.run(args)
|
|
@@ -112,6 +112,7 @@ module Belt
|
|
|
112
112
|
sqs_triggers SQS queue triggers
|
|
113
113
|
reserved_concurrency Reserved concurrency limit
|
|
114
114
|
ephemeral_storage Ephemeral storage in MB (512-10240)
|
|
115
|
+
iam_policy_arns Additional IAM policy ARNs (supports ref())
|
|
115
116
|
HELP
|
|
116
117
|
end
|
|
117
118
|
|
|
@@ -14,9 +14,10 @@ module Belt
|
|
|
14
14
|
non_param = segments.reject { |s| s.start_with?(':', '{') }
|
|
15
15
|
return gateway.name if non_param.empty?
|
|
16
16
|
|
|
17
|
-
# Nested resources (/posts/{id}/comments)
|
|
18
|
-
#
|
|
19
|
-
|
|
17
|
+
# Nested resources (/posts/{id}/comments): use the last non-param segment
|
|
18
|
+
# as the controller name (matches Rails — nesting affects URL, not controller lookup).
|
|
19
|
+
# Scoped resources (/admin/users) still use the full path when controller is explicitly set.
|
|
20
|
+
return non_param.last.gsub('-', '_') if route.resource? && non_param.length > 1
|
|
20
21
|
|
|
21
22
|
# For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
|
|
22
23
|
# the segment is the action name, not the controller. Use the gateway name as controller.
|
|
@@ -250,18 +250,8 @@ module Belt
|
|
|
250
250
|
def output_concise(routes)
|
|
251
251
|
return puts('No routes defined.') if routes.empty?
|
|
252
252
|
|
|
253
|
-
multi_gateway = routes.map { |r| r[:gateway] }.uniq.length > 1
|
|
254
253
|
verb_w = [routes.map { |r| r[:verb].length }.max, 6].max
|
|
255
254
|
path_w = [routes.map { |r| r[:path].length }.max, 4].max
|
|
256
|
-
|
|
257
|
-
if multi_gateway
|
|
258
|
-
output_concise_multi_gateway(routes, verb_w, path_w)
|
|
259
|
-
else
|
|
260
|
-
output_concise_single_gateway(routes, verb_w, path_w)
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def output_concise_multi_gateway(routes, verb_w, path_w)
|
|
265
255
|
gw_w = [routes.map { |r| r[:gateway].to_s.length }.max, 7].max
|
|
266
256
|
lam_w = [routes.map { |r| r[:lambda].length }.max, 6].max
|
|
267
257
|
|
|
@@ -278,15 +268,6 @@ module Belt
|
|
|
278
268
|
end
|
|
279
269
|
end
|
|
280
270
|
|
|
281
|
-
def output_concise_single_gateway(routes, verb_w, path_w)
|
|
282
|
-
puts "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} CONTROLLER#ACTION"
|
|
283
|
-
puts '-' * (verb_w + path_w + 30)
|
|
284
|
-
|
|
285
|
-
routes.each do |r|
|
|
286
|
-
puts "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} #{r[:controller]}##{r[:action]}"
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
|
|
290
271
|
def route_specificity(path, verb)
|
|
291
272
|
segments = path.split('/').reject(&:empty?)
|
|
292
273
|
param_count = segments.count { |s| s.start_with?('{') }
|
|
@@ -75,10 +75,11 @@ module Belt
|
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
def initialize(name, fields, force: false)
|
|
78
|
+
def initialize(name, fields, force: false, quiet: false)
|
|
79
79
|
@name = name.downcase.gsub(/[^a-z0-9_]/, '_')
|
|
80
80
|
@fields = fields
|
|
81
81
|
@force = force
|
|
82
|
+
@quiet = quiet
|
|
82
83
|
@overwrite_all = false
|
|
83
84
|
@singular_name = Belt::Inflector.singularize(@name)
|
|
84
85
|
@resource_name = Belt::Inflector.pluralize(@singular_name)
|
|
@@ -103,14 +104,7 @@ module Belt
|
|
|
103
104
|
|
|
104
105
|
inject_routes
|
|
105
106
|
|
|
106
|
-
puts "\n✓ Views for '#{@singular_name}' generated!"
|
|
107
|
-
puts "\nFiles created:"
|
|
108
|
-
puts " #{pages_dir}/#{@plural_class_name}Index.jsx"
|
|
109
|
-
puts " #{pages_dir}/#{@class_name}Show.jsx"
|
|
110
|
-
puts " #{pages_dir}/#{@class_name}New.jsx"
|
|
111
|
-
puts " #{pages_dir}/#{@class_name}Edit.jsx"
|
|
112
|
-
puts " #{pages_dir}/#{@class_name}Form.jsx"
|
|
113
|
-
puts ' frontend/src/App.jsx (updated)'
|
|
107
|
+
puts "\n✓ Views for '#{@singular_name}' generated!" unless @quiet
|
|
114
108
|
end
|
|
115
109
|
|
|
116
110
|
private
|
data/lib/belt/cli.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative 'cli/env_resolver'
|
|
|
6
6
|
require_relative 'cli/new_command'
|
|
7
7
|
require_relative 'cli/generate_command'
|
|
8
8
|
require_relative 'cli/destroy_command'
|
|
9
|
+
require_relative 'cli/index_command'
|
|
9
10
|
require_relative 'cli/frontend_command'
|
|
10
11
|
require_relative 'cli/frontend_setup_command'
|
|
11
12
|
require_relative 'cli/frontend_deploy_command'
|
data/lib/belt/inflector.rb
CHANGED
|
@@ -42,6 +42,14 @@ module Belt
|
|
|
42
42
|
word.to_s.split('_').map(&:capitalize).join
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# "conversation_id" → "conversationId"
|
|
46
|
+
def camelize_lower(word)
|
|
47
|
+
parts = word.to_s.split('_')
|
|
48
|
+
return word if parts.empty?
|
|
49
|
+
|
|
50
|
+
parts.first + parts[1..].map(&:capitalize).join
|
|
51
|
+
end
|
|
52
|
+
|
|
45
53
|
# "BlogPost" → "blog_post"
|
|
46
54
|
def underscore(word)
|
|
47
55
|
word.to_s
|
data/lib/belt/route_dsl.rb
CHANGED
|
@@ -65,8 +65,9 @@ module Belt
|
|
|
65
65
|
resource_name = name.to_s
|
|
66
66
|
singular = @gateway.send(:singularize, resource_name)
|
|
67
67
|
param_name = options[:param] || "#{singular}_id"
|
|
68
|
+
# Auto-add this resource's table before merging inherited tables
|
|
69
|
+
options = options.merge(tables: [resource_name.to_sym]) unless options.key?(:tables)
|
|
68
70
|
options = merge_inherited_options(options)
|
|
69
|
-
options = @gateway.send(:auto_infer_tables, resource_name, options)
|
|
70
71
|
resource_options = options.merge(route_type: :resources)
|
|
71
72
|
actions = @gateway.send(:determine_actions, options)
|
|
72
73
|
|
|
@@ -156,7 +157,7 @@ module Belt
|
|
|
156
157
|
def initialize(name, options = {})
|
|
157
158
|
@name = name.to_s
|
|
158
159
|
@routes = []
|
|
159
|
-
@default_auth = options[:auth] || :
|
|
160
|
+
@default_auth = options[:auth] || :none
|
|
160
161
|
@default_lambda = options[:lambda] || name
|
|
161
162
|
@default_cors = options.fetch(:cors, true)
|
|
162
163
|
@default_tables = Array(options[:tables] || [])
|
data/lib/belt/version.rb
CHANGED
|
@@ -13,6 +13,14 @@ resource "aws_cognito_user_pool" "<%= pool[:name] %>" {
|
|
|
13
13
|
require_uppercase = true
|
|
14
14
|
}
|
|
15
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 -%>
|
|
16
24
|
auto_verified_attributes = ["email"]
|
|
17
25
|
|
|
18
26
|
account_recovery_setting {
|
|
@@ -43,6 +51,9 @@ resource "aws_cognito_user_pool_client" "<%= pool[:name] %>" {
|
|
|
43
51
|
|
|
44
52
|
explicit_auth_flows = [
|
|
45
53
|
"ALLOW_USER_SRP_AUTH",
|
|
54
|
+
<% if @signup -%>
|
|
55
|
+
"ALLOW_USER_PASSWORD_AUTH",
|
|
56
|
+
<% end -%>
|
|
46
57
|
"ALLOW_REFRESH_TOKEN_AUTH"
|
|
47
58
|
]
|
|
48
59
|
|
|
@@ -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,79 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { signIn, completeNewPassword } from '../../lib/auth'
|
|
3
|
+
import './auth.css'
|
|
4
|
+
|
|
5
|
+
export default function Login({ onLogin }) {
|
|
6
|
+
const [email, setEmail] = useState('')
|
|
7
|
+
const [password, setPassword] = useState('')
|
|
8
|
+
const [newPassword, setNewPassword] = useState('')
|
|
9
|
+
const [error, setError] = useState('')
|
|
10
|
+
const [challenge, setChallenge] = useState(null)
|
|
11
|
+
const [loading, setLoading] = useState(false)
|
|
12
|
+
|
|
13
|
+
async function handleSubmit(e) {
|
|
14
|
+
e.preventDefault()
|
|
15
|
+
setError('')
|
|
16
|
+
setLoading(true)
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const result = await signIn(email, password)
|
|
20
|
+
if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
|
|
21
|
+
setChallenge(result)
|
|
22
|
+
} else {
|
|
23
|
+
onLogin()
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
setError(err.message || 'Sign in failed')
|
|
27
|
+
} finally {
|
|
28
|
+
setLoading(false)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function handleNewPassword(e) {
|
|
33
|
+
e.preventDefault()
|
|
34
|
+
setError('')
|
|
35
|
+
setLoading(true)
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await completeNewPassword(email, newPassword, challenge.session)
|
|
39
|
+
onLogin()
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err.message || 'Password change failed')
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (challenge) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="auth-page">
|
|
50
|
+
<form className="auth-form" onSubmit={handleNewPassword}>
|
|
51
|
+
<h2>Set New Password</h2>
|
|
52
|
+
<p>Please set a permanent password for your account.</p>
|
|
53
|
+
{error && <div className="auth-error">{error}</div>}
|
|
54
|
+
<input type="password" placeholder="New password" value={newPassword}
|
|
55
|
+
onChange={e => setNewPassword(e.target.value)} required autoFocus />
|
|
56
|
+
<button type="submit" disabled={loading}>
|
|
57
|
+
{loading ? 'Saving...' : 'Set Password'}
|
|
58
|
+
</button>
|
|
59
|
+
</form>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="auth-page">
|
|
66
|
+
<form className="auth-form" onSubmit={handleSubmit}>
|
|
67
|
+
<h2>Sign In</h2>
|
|
68
|
+
{error && <div className="auth-error">{error}</div>}
|
|
69
|
+
<input type="email" placeholder="Email" value={email}
|
|
70
|
+
onChange={e => setEmail(e.target.value)} required autoFocus />
|
|
71
|
+
<input type="password" placeholder="Password" value={password}
|
|
72
|
+
onChange={e => setPassword(e.target.value)} required />
|
|
73
|
+
<button type="submit" disabled={loading}>
|
|
74
|
+
{loading ? 'Signing in...' : 'Sign In'}
|
|
75
|
+
</button>
|
|
76
|
+
</form>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -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
|
+
}
|