belt 0.2.17 → 0.3.14
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 +226 -0
- data/README.md +42 -3
- data/lib/belt/action_router.rb +34 -19
- data/lib/belt/cli/app_detection.rb +30 -3
- data/lib/belt/cli/auth_command.rb +395 -0
- data/lib/belt/cli/console_command.rb +17 -2
- data/lib/belt/cli/deploy_command.rb +51 -17
- data/lib/belt/cli/destroy_command.rb +62 -25
- data/lib/belt/cli/doctor_command.rb +166 -22
- data/lib/belt/cli/environment_command.rb +12 -0
- data/lib/belt/cli/explain_command.rb +112 -0
- data/lib/belt/cli/frontend_command.rb +31 -10
- data/lib/belt/cli/frontend_deploy_command.rb +110 -21
- data/lib/belt/cli/frontend_env_command.rb +56 -19
- data/lib/belt/cli/frontend_env_map.rb +30 -10
- data/lib/belt/cli/frontend_registry.rb +373 -0
- data/lib/belt/cli/frontend_setup_command.rb +71 -5
- data/lib/belt/cli/generate_command.rb +61 -42
- data/lib/belt/cli/index_command.rb +192 -0
- data/lib/belt/cli/lambda_config_command.rb +9 -3
- data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
- data/lib/belt/cli/routes_command/route_inference.rb +4 -3
- data/lib/belt/cli/routes_command.rb +31 -19
- data/lib/belt/cli/server_command.rb +28 -15
- data/lib/belt/cli/tables_command.rb +3 -1
- data/lib/belt/cli/terraform_command.rb +9 -0
- data/lib/belt/cli/views_command.rb +15 -15
- data/lib/belt/cli/zip_artifact_builder.rb +199 -0
- data/lib/belt/cli.rb +17 -9
- data/lib/belt/docs/backups.md +101 -0
- data/lib/belt/docs/console.md +65 -0
- data/lib/belt/docs/controllers.md +155 -0
- data/lib/belt/docs/deployment.md +166 -0
- data/lib/belt/docs/frontend.md +103 -0
- data/lib/belt/docs/generators.md +137 -0
- data/lib/belt/docs/lambda_handler.md +105 -0
- data/lib/belt/docs/models.md +160 -0
- data/lib/belt/docs/observability.md +94 -0
- data/lib/belt/docs/plugins.md +94 -0
- data/lib/belt/docs/routing.md +138 -0
- data/lib/belt/docs/structure.md +96 -0
- data/lib/belt/inflector.rb +8 -0
- data/lib/belt/route_dsl.rb +318 -100
- data/lib/belt/version.rb +1 -1
- data/lib/templates/frontend/react/env.yml.example +2 -2
- data/lib/templates/generate/auth/cognito.tf.erb +71 -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 +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 +121 -0
- data/lib/templates/generate/model.rb.erb +4 -6
- data/lib/templates/module/frontend.tf.erb +49 -43
- data/lib/templates/module/outputs.tf.erb +1 -1
- data/lib/templates/new_app/AGENTS.md.erb +170 -20
- data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
- data/lib/templates/new_app/config/routes.rb.erb +8 -3
- data/lib/templates/new_app/lambda/api.rb.erb +1 -1
- metadata +56 -1
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
"ALLOW_USER_PASSWORD_AUTH",
|
|
55
|
+
"ALLOW_REFRESH_TOKEN_AUTH"
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
prevent_user_existence_errors = "ENABLED"
|
|
59
|
+
|
|
60
|
+
token_validity_units {
|
|
61
|
+
access_token = "hours"
|
|
62
|
+
id_token = "hours"
|
|
63
|
+
refresh_token = "days"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
access_token_validity = 1
|
|
67
|
+
id_token_validity = 1
|
|
68
|
+
refresh_token_validity = 30
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
<% 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,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
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
.auth-page {
|
|
2
|
+
--auth-bg: #f6f8fa;
|
|
3
|
+
--auth-card-bg: #ffffff;
|
|
4
|
+
--auth-border: #d0d7de;
|
|
5
|
+
--auth-text: #1f2328;
|
|
6
|
+
--auth-muted: #656d76;
|
|
7
|
+
--auth-input-bg: #f6f8fa;
|
|
8
|
+
--auth-btn: #2da44e;
|
|
9
|
+
--auth-btn-hover: #2c974b;
|
|
10
|
+
--auth-btn-disabled-bg: #94d3a2;
|
|
11
|
+
--auth-btn-disabled-text: #fff;
|
|
12
|
+
--auth-error-bg: rgba(248, 81, 73, 0.08);
|
|
13
|
+
--auth-error-border: rgba(248, 81, 73, 0.4);
|
|
14
|
+
--auth-error-text: #cf222e;
|
|
15
|
+
--auth-link: #0969da;
|
|
16
|
+
--auth-focus: #0969da;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
.auth-page {
|
|
21
|
+
--auth-bg: #0d1117;
|
|
22
|
+
--auth-card-bg: #161b22;
|
|
23
|
+
--auth-border: #30363d;
|
|
24
|
+
--auth-text: #e6edf3;
|
|
25
|
+
--auth-muted: #8b949e;
|
|
26
|
+
--auth-input-bg: #0d1117;
|
|
27
|
+
--auth-btn: #238636;
|
|
28
|
+
--auth-btn-hover: #2ea043;
|
|
29
|
+
--auth-btn-disabled-bg: #21262d;
|
|
30
|
+
--auth-btn-disabled-text: #484f58;
|
|
31
|
+
--auth-error-bg: rgba(248, 81, 73, 0.1);
|
|
32
|
+
--auth-error-border: rgba(248, 81, 73, 0.4);
|
|
33
|
+
--auth-error-text: #f85149;
|
|
34
|
+
--auth-link: #58a6ff;
|
|
35
|
+
--auth-focus: #58a6ff;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.auth-page {
|
|
40
|
+
min-height: 100vh;
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: center;
|
|
44
|
+
background: var(--auth-bg);
|
|
45
|
+
padding: 1rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.auth-form {
|
|
49
|
+
background: var(--auth-card-bg);
|
|
50
|
+
border: 1px solid var(--auth-border);
|
|
51
|
+
border-radius: 12px;
|
|
52
|
+
padding: 2.5rem;
|
|
53
|
+
width: 100%;
|
|
54
|
+
max-width: 380px;
|
|
55
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.auth-form h2 {
|
|
59
|
+
color: var(--auth-text);
|
|
60
|
+
font-size: 1.5rem;
|
|
61
|
+
margin: 0 0 0.5rem;
|
|
62
|
+
text-align: center;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.auth-form p {
|
|
66
|
+
color: var(--auth-muted);
|
|
67
|
+
font-size: 0.85rem;
|
|
68
|
+
text-align: center;
|
|
69
|
+
margin: 0 0 1.5rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.auth-form input {
|
|
73
|
+
display: block;
|
|
74
|
+
width: 100%;
|
|
75
|
+
padding: 0.75rem 1rem;
|
|
76
|
+
margin-bottom: 0.75rem;
|
|
77
|
+
background: var(--auth-input-bg);
|
|
78
|
+
border: 1px solid var(--auth-border);
|
|
79
|
+
border-radius: 8px;
|
|
80
|
+
color: var(--auth-text);
|
|
81
|
+
font-size: 0.9rem;
|
|
82
|
+
outline: none;
|
|
83
|
+
transition: border-color 0.2s;
|
|
84
|
+
box-sizing: border-box;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.auth-form input:focus {
|
|
88
|
+
border-color: var(--auth-focus);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.auth-form input::placeholder {
|
|
92
|
+
color: var(--auth-muted);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.auth-form button {
|
|
96
|
+
display: block;
|
|
97
|
+
width: 100%;
|
|
98
|
+
padding: 0.75rem;
|
|
99
|
+
margin-top: 0.5rem;
|
|
100
|
+
background: var(--auth-btn);
|
|
101
|
+
border: none;
|
|
102
|
+
border-radius: 8px;
|
|
103
|
+
color: #fff;
|
|
104
|
+
font-size: 0.9rem;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
transition: background 0.2s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.auth-form button:hover {
|
|
111
|
+
background: var(--auth-btn-hover);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.auth-form button:disabled {
|
|
115
|
+
background: var(--auth-btn-disabled-bg);
|
|
116
|
+
color: var(--auth-btn-disabled-text);
|
|
117
|
+
cursor: not-allowed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.auth-error {
|
|
121
|
+
background: var(--auth-error-bg);
|
|
122
|
+
border: 1px solid var(--auth-error-border);
|
|
123
|
+
border-radius: 8px;
|
|
124
|
+
color: var(--auth-error-text);
|
|
125
|
+
padding: 0.6rem 0.75rem;
|
|
126
|
+
font-size: 0.8rem;
|
|
127
|
+
margin-bottom: 1rem;
|
|
128
|
+
text-align: center;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.auth-link {
|
|
132
|
+
color: var(--auth-muted);
|
|
133
|
+
font-size: 0.8rem;
|
|
134
|
+
text-align: center;
|
|
135
|
+
margin-top: 1.25rem !important;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.auth-link a {
|
|
139
|
+
color: var(--auth-link);
|
|
140
|
+
text-decoration: none;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.auth-link a:hover {
|
|
144
|
+
text-decoration: underline;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.auth-btn {
|
|
148
|
+
display: inline-block;
|
|
149
|
+
padding: 0.6rem 1.5rem;
|
|
150
|
+
background: var(--auth-btn);
|
|
151
|
+
border-radius: 8px;
|
|
152
|
+
color: #fff;
|
|
153
|
+
text-decoration: none;
|
|
154
|
+
font-weight: 600;
|
|
155
|
+
font-size: 0.85rem;
|
|
156
|
+
margin-top: 1rem;
|
|
157
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CognitoUserPool,
|
|
3
|
+
CognitoUser,
|
|
4
|
+
AuthenticationDetails,
|
|
5
|
+
CognitoUserAttribute
|
|
6
|
+
} from 'amazon-cognito-identity-js'
|
|
7
|
+
|
|
8
|
+
const REGION = import.meta.env.VITE_AWS_REGION
|
|
9
|
+
const CLIENT_ID = import.meta.env.VITE_COGNITO_CLIENT_ID
|
|
10
|
+
const USER_POOL_ID = import.meta.env.VITE_COGNITO_USER_POOL_ID
|
|
11
|
+
|
|
12
|
+
// amazon-cognito-identity-js uses SRP (Secure Remote Password) by default.
|
|
13
|
+
// The password is NEVER sent over the wire — only the SRP_A proof is
|
|
14
|
+
// transmitted, so credentials can't leak into request logs or proxies.
|
|
15
|
+
const userPool = new CognitoUserPool({
|
|
16
|
+
UserPoolId: USER_POOL_ID,
|
|
17
|
+
ClientId: CLIENT_ID
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
let idToken = null
|
|
21
|
+
let refreshToken = null
|
|
22
|
+
|
|
23
|
+
export function getToken() { return idToken }
|
|
24
|
+
export function isAuthenticated() { return idToken !== null }
|
|
25
|
+
|
|
26
|
+
export function signIn(username, password) {
|
|
27
|
+
const cognitoUser = new CognitoUser({ Username: username, Pool: userPool })
|
|
28
|
+
const authDetails = new AuthenticationDetails({ Username: username, Password: password })
|
|
29
|
+
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
cognitoUser.authenticateUser(authDetails, {
|
|
32
|
+
onSuccess: (session) => {
|
|
33
|
+
setTokens({
|
|
34
|
+
IdToken: session.getIdToken().getJwtToken(),
|
|
35
|
+
RefreshToken: session.getRefreshToken().getToken()
|
|
36
|
+
})
|
|
37
|
+
resolve({ success: true })
|
|
38
|
+
},
|
|
39
|
+
onFailure: (err) => reject(err),
|
|
40
|
+
newPasswordRequired: () => {
|
|
41
|
+
// Stash the user so completeNewPassword can continue the SRP session.
|
|
42
|
+
pendingUser = cognitoUser
|
|
43
|
+
resolve({ challenge: 'NEW_PASSWORD_REQUIRED' })
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Holds the CognitoUser mid-challenge for the NEW_PASSWORD_REQUIRED flow.
|
|
50
|
+
let pendingUser = null
|
|
51
|
+
|
|
52
|
+
export function completeNewPassword(email, newPassword) {
|
|
53
|
+
if (!pendingUser) {
|
|
54
|
+
return Promise.reject(new Error('No pending password challenge. Sign in again.'))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Pools that require `email` reject the NEW_PASSWORD_REQUIRED challenge with
|
|
58
|
+
// "Invalid attributes given, email is missing" unless we supply it here.
|
|
59
|
+
// Admin-created users hitting this challenge don't have email set yet, so we
|
|
60
|
+
// pass the username (which is the email) as the required attribute.
|
|
61
|
+
const requiredAttributes = { email }
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
pendingUser.completeNewPasswordChallenge(newPassword, requiredAttributes, {
|
|
65
|
+
onSuccess: (session) => {
|
|
66
|
+
setTokens({
|
|
67
|
+
IdToken: session.getIdToken().getJwtToken(),
|
|
68
|
+
RefreshToken: session.getRefreshToken().getToken()
|
|
69
|
+
})
|
|
70
|
+
pendingUser = null
|
|
71
|
+
resolve({ success: true })
|
|
72
|
+
},
|
|
73
|
+
onFailure: (err) => {
|
|
74
|
+
pendingUser = null
|
|
75
|
+
reject(err)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function signUp(email, password) {
|
|
82
|
+
const attributes = [new CognitoUserAttribute({ Name: 'email', Value: email })]
|
|
83
|
+
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
userPool.signUp(email, password, attributes, null, (err) => {
|
|
86
|
+
if (err) return reject(err)
|
|
87
|
+
resolve({ needsConfirmation: true })
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function confirmSignUp(email, code) {
|
|
93
|
+
const cognitoUser = new CognitoUser({ Username: email, Pool: userPool })
|
|
94
|
+
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
cognitoUser.confirmRegistration(code, true, (err) => {
|
|
97
|
+
if (err) return reject(err)
|
|
98
|
+
resolve({ success: true })
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function signOut() {
|
|
104
|
+
const cognitoUser = userPool.getCurrentUser()
|
|
105
|
+
if (cognitoUser) cognitoUser.signOut()
|
|
106
|
+
idToken = null
|
|
107
|
+
refreshToken = null
|
|
108
|
+
localStorage.removeItem('idToken')
|
|
109
|
+
localStorage.removeItem('refreshToken')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function setTokens(authResult) {
|
|
113
|
+
idToken = authResult.IdToken
|
|
114
|
+
refreshToken = authResult.RefreshToken
|
|
115
|
+
localStorage.setItem('idToken', idToken)
|
|
116
|
+
if (refreshToken) localStorage.setItem('refreshToken', refreshToken)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Restore on page load
|
|
120
|
+
const stored = localStorage.getItem('idToken')
|
|
121
|
+
if (stored) idToken = stored
|
|
@@ -4,13 +4,11 @@ class <%= @class_name %> < ApplicationRecord
|
|
|
4
4
|
<% @references.each do |ref| -%>
|
|
5
5
|
belongs_to :<%= ref[:referenced_model] %>
|
|
6
6
|
<% end -%>
|
|
7
|
-
<% if @references.any? &&
|
|
7
|
+
<% if @references.any? && @regular_fields.any? -%>
|
|
8
8
|
|
|
9
9
|
<% end -%>
|
|
10
|
-
<% @
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<% @regular_fields.each do |field| -%>
|
|
14
|
-
attr_accessor :<%= field[:name] %>
|
|
10
|
+
<% all_accessors = @regular_fields.map { |f| ":#{f[:name]}" } -%>
|
|
11
|
+
<% if all_accessors.any? -%>
|
|
12
|
+
attr_accessor <%= all_accessors.join(', ') %>
|
|
15
13
|
<% end -%>
|
|
16
14
|
end
|