gemstack-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +24 -0
- data/exe/gemstack +6 -0
- data/lib/gemstack/cli/add_generator.rb +237 -0
- data/lib/gemstack/cli/app_generator.rb +110 -0
- data/lib/gemstack/cli/commands/db.rb +134 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +64 -0
- data/lib/gemstack/cli/doctor.rb +277 -0
- data/lib/gemstack/cli/generator.rb +122 -0
- data/lib/gemstack/cli/job_generator.rb +47 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +169 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +299 -0
- data/templates/app/Gemfile.tt +39 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +30 -0
- data/templates/app/config/environments/development.rb +6 -0
- data/templates/app/config/environments/production.rb +8 -0
- data/templates/app/config/environments/test.rb +7 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example +9 -0
- data/templates/app/dot_gitignore +14 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +1 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +62 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +80 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.tsx.tt +45 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +195 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# <%= module_name %>
|
|
2
|
+
|
|
3
|
+
A [GemStack](https://github.com/gemstack/gemstack) application: a Ruby API with a
|
|
4
|
+
Next.js + TypeScript frontend, served from one origin.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
gemstack dev # http://localhost:3000 (/api/* → Ruby, everything else → Next.js)
|
|
8
|
+
gemstack routes # list API routes
|
|
9
|
+
gemstack test # run the Ruby tests
|
|
10
|
+
gemstack console # IRB with the app loaded
|
|
11
|
+
gemstack generate controller Status show
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Layout
|
|
15
|
+
|
|
16
|
+
| Path | What lives there |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `config/app.rb` | configuration (`GemStack.configure`) |
|
|
19
|
+
| `config/routes.rb` | API routes, relative to `/api` |
|
|
20
|
+
| `app/` | autoloaded code: `controllers/`, and any folder you add (`services/`, ...) |
|
|
21
|
+
| `test/` | Minitest + rack-test (`GemStack::TestCase`) |
|
|
22
|
+
| `frontend/` | the Next.js app; `lib/gemstack/client.ts` talks to the API |
|
|
23
|
+
|
|
24
|
+
## Production
|
|
25
|
+
|
|
26
|
+
Run the API with `gemstack server` (Puma, `config/puma.rb`) and the frontend
|
|
27
|
+
with `npm run build && npm start` in `frontend/`. Either route `/api/*` to the
|
|
28
|
+
API with your reverse proxy, or set `GEMSTACK_API_URL` for the Next.js process
|
|
29
|
+
and it will proxy `/api/*` itself.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "gemstack"
|
|
5
|
+
|
|
6
|
+
# Sets the application root and loads .env files (development/test).
|
|
7
|
+
GemStack.setup(root: File.expand_path("..", __dir__))
|
|
8
|
+
|
|
9
|
+
# Require the gems listed in the Gemfile for the current environment.
|
|
10
|
+
Bundler.require(:default, GemStack.env.to_sym)
|
|
11
|
+
|
|
12
|
+
GemStack.configure do |config|
|
|
13
|
+
config.name = "<%= name %>"
|
|
14
|
+
|
|
15
|
+
# Everything below is optional — these are the defaults. See docs/configuration.md.
|
|
16
|
+
#
|
|
17
|
+
# config.http.api_path = "/api" # where the API lives (routes are relative to it)
|
|
18
|
+
# config.http.max_body_size = 10 * 1024 * 1024 # bytes; larger requests get 413
|
|
19
|
+
# config.http.json = :json # or :oj (add gem "oj"), or your own codec
|
|
20
|
+
# config.http.cors.origins = [] # only for frontends on another domain
|
|
21
|
+
# config.http.middleware.use MyMiddleware # add / insert_before / swap / delete
|
|
22
|
+
# config.logger.level = :debug
|
|
23
|
+
# config.filter_parameters += %w[iban] # masked in logs
|
|
24
|
+
<% if database? -%>
|
|
25
|
+
#
|
|
26
|
+
# config.db.url = ENV["DATABASE_URL"] # default: postgres:///<name>_<env> (TEST_DATABASE_URL in test)
|
|
27
|
+
# config.db.pool_size = 5 # default: GEMSTACK_MAX_THREADS (one per Puma thread)
|
|
28
|
+
# config.db.statement_timeout = 5_000 # ms
|
|
29
|
+
<% end -%>
|
|
30
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Production-only settings. Defaults here: JSON logs, no exception details in
|
|
4
|
+
# responses, eager loading, HSTS on HTTPS requests.
|
|
5
|
+
GemStack.configure do |config|
|
|
6
|
+
# config.logger.level = :info
|
|
7
|
+
# config.http.cors.origins = ["https://app.example.com"] # only if the frontend is on another domain
|
|
8
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Puma configuration. Every value can be set through the environment.
|
|
4
|
+
# `gemstack dev` sets GEMSTACK_API_HOST/PORT to a private loopback port
|
|
5
|
+
# behind the development gateway.
|
|
6
|
+
|
|
7
|
+
max_threads = Integer(ENV.fetch("GEMSTACK_MAX_THREADS", 5))
|
|
8
|
+
min_threads = Integer(ENV.fetch("GEMSTACK_MIN_THREADS", max_threads))
|
|
9
|
+
threads min_threads, max_threads
|
|
10
|
+
|
|
11
|
+
bind "tcp://#{ENV.fetch("GEMSTACK_API_HOST", "0.0.0.0")}:#{ENV.fetch("GEMSTACK_API_PORT", 4000)}"
|
|
12
|
+
environment ENV.fetch("GEMSTACK_ENV", "development")
|
|
13
|
+
|
|
14
|
+
# Cluster mode for multi-core production servers: WEB_CONCURRENCY=4
|
|
15
|
+
worker_count = Integer(ENV.fetch("WEB_CONCURRENCY", 0))
|
|
16
|
+
if worker_count.positive?
|
|
17
|
+
workers worker_count
|
|
18
|
+
preload_app!
|
|
19
|
+
# Connections must not be shared across forked workers; each reconnects lazily.
|
|
20
|
+
before_fork { GemStack::DB.disconnect if defined?(GemStack::DB) }
|
|
21
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# API routes, relative to config.http.api_path ("/api"):
|
|
4
|
+
#
|
|
5
|
+
# get "/status", to: "status#show" # GET /api/status → StatusController#show
|
|
6
|
+
# resources :products # /api/products: index, create, show, update, destroy
|
|
7
|
+
#
|
|
8
|
+
# List them with `gemstack routes`. In development this file reloads on save.
|
|
9
|
+
GemStack.routes do
|
|
10
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copy to .env for local development overrides. Never commit real secrets.
|
|
2
|
+
# Variables already set in your shell always take precedence.
|
|
3
|
+
|
|
4
|
+
# PORT=3000 # public development port (gemstack dev)
|
|
5
|
+
# GEMSTACK_LOG_LEVEL=debug
|
|
6
|
+
|
|
7
|
+
# PostgreSQL. Defaults: postgres:///<app>_development locally (current OS user).
|
|
8
|
+
# DATABASE_URL=postgres://user:password@localhost:5432/myapp_development
|
|
9
|
+
# TEST_DATABASE_URL=postgres://user:password@localhost:5432/myapp_test
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= ruby_version %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby <%= ruby_version %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class HealthTest < GemStack::TestCase
|
|
6
|
+
def test_api_boots_and_answers_health_checks
|
|
7
|
+
get_json "/api/health"
|
|
8
|
+
|
|
9
|
+
assert_status 200
|
|
10
|
+
assert_equal({ "status" => "ok" }, json_body)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def test_unknown_routes_return_json_errors
|
|
14
|
+
get_json "/api/does-not-exist"
|
|
15
|
+
|
|
16
|
+
assert_error 404, "route_not_found"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ENV["GEMSTACK_ENV"] = "test"
|
|
4
|
+
|
|
5
|
+
require_relative "../config/app"
|
|
6
|
+
require "gemstack/testing"
|
|
7
|
+
require "gemstack/jobs/testing"
|
|
8
|
+
<% if database? -%>
|
|
9
|
+
require "gemstack/db/testing"
|
|
10
|
+
<% end -%>
|
|
11
|
+
require "minitest/autorun"
|
|
12
|
+
|
|
13
|
+
<% if database? -%>
|
|
14
|
+
# Creates the test database (TEST_DATABASE_URL) if needed and applies migrations.
|
|
15
|
+
GemStack::DB::Testing.prepare!
|
|
16
|
+
<% end -%>
|
|
17
|
+
GemStack.boot!
|
|
18
|
+
<% if database? -%>
|
|
19
|
+
|
|
20
|
+
# Every test runs in a transaction that is rolled back afterwards.
|
|
21
|
+
GemStack::TestCase.include GemStack::DB::Testing::Transactions
|
|
22
|
+
<% end -%>
|
|
23
|
+
|
|
24
|
+
# Jobs are recorded, not run: assert_enqueued / perform_enqueued_jobs.
|
|
25
|
+
GemStack::TestCase.include GemStack::Jobs::Testing
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Personal API tokens: send `Authorization: Bearer <token>` instead of a cookie.
|
|
4
|
+
# GET /api/auth/tokens · POST /api/auth/tokens · DELETE /api/auth/tokens/:id
|
|
5
|
+
class ApiTokensController < ApplicationController
|
|
6
|
+
before :require_login
|
|
7
|
+
before :require_session, only: %i[create destroy] # a leaked token can't mint or revoke tokens
|
|
8
|
+
|
|
9
|
+
accepts :create do
|
|
10
|
+
required :name, :string, max_length: 100
|
|
11
|
+
end
|
|
12
|
+
returns :index, [ApiTokenSerializer]
|
|
13
|
+
returns :create, NewApiTokenSerializer
|
|
14
|
+
|
|
15
|
+
def index = render(current_user.auth_tokens_dataset.api.order(Sequel.desc(:created_at)), serializer: ApiTokenSerializer)
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
token, id = GemStack::Auth::Tokens.issue(current_user.id, purpose: "api", name: input[:name])
|
|
19
|
+
render NewApiTokenSerializer::Result.new(token: token, api_token: AuthToken[id]),
|
|
20
|
+
status: :created, serializer: NewApiTokenSerializer
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def destroy
|
|
24
|
+
revoked = GemStack::Auth::Tokens.revoke(params[:id], user_id: current_user.id)
|
|
25
|
+
raise GemStack::NotFound, "API token not found" unless revoked
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def require_session
|
|
31
|
+
return if authenticated_by == :session
|
|
32
|
+
|
|
33
|
+
raise GemStack::Forbidden.new("Manage API tokens from the app.", code: "session_required")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# POST /api/auth/email/resend · POST /api/auth/email/verify
|
|
4
|
+
class EmailVerificationsController < ApplicationController
|
|
5
|
+
before :require_login, only: :create
|
|
6
|
+
rate_limit to: 5, within: 3600, only: :create
|
|
7
|
+
rate_limit to: 20, within: 3600, only: :update
|
|
8
|
+
|
|
9
|
+
accepts :update do
|
|
10
|
+
required :token, :string, max_length: 200
|
|
11
|
+
end
|
|
12
|
+
returns :create, nil
|
|
13
|
+
returns :update, nil
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
AuthMailer.email_verification(current_user.id).deliver_later unless current_user.email_verified?
|
|
17
|
+
head :accepted
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Works signed in or out: the link may be opened on another device.
|
|
21
|
+
def update
|
|
22
|
+
row = GemStack::Auth::Tokens.consume(input[:token], purpose: "email_verification")
|
|
23
|
+
user = row && User[row[:user_id]]
|
|
24
|
+
# Only valid for the address it was sent to.
|
|
25
|
+
unless user && user.email == row[:email]
|
|
26
|
+
raise GemStack::BadRequest.new("This verification link is invalid or has expired.", code: "invalid_token")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
user.update(email_verified_at: Time.now) unless user.email_verified?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# POST /api/auth/password/forgot · POST /api/auth/password/reset
|
|
4
|
+
class PasswordResetsController < ApplicationController
|
|
5
|
+
rate_limit to: 10, within: 3600, only: :create
|
|
6
|
+
rate_limit to: 3, within: 3600, only: :create, name: "reset-email", by: -> { params[:email].to_s.downcase }
|
|
7
|
+
rate_limit to: 10, within: 3600, only: :update
|
|
8
|
+
|
|
9
|
+
accepts :create do
|
|
10
|
+
required :email, :string, max_length: 254
|
|
11
|
+
end
|
|
12
|
+
accepts :update do
|
|
13
|
+
required :token, :string, max_length: 200
|
|
14
|
+
required :password, :string, max_length: 1024
|
|
15
|
+
end
|
|
16
|
+
returns :create, nil
|
|
17
|
+
returns :update, UserSerializer
|
|
18
|
+
|
|
19
|
+
# Always 202: whether an account exists is nobody else's business.
|
|
20
|
+
def create
|
|
21
|
+
user = User.first(email: User.normalize_email(input[:email]))
|
|
22
|
+
AuthMailer.password_reset(user.id).deliver_later if user
|
|
23
|
+
head :accepted
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update
|
|
27
|
+
errors = GemStack::Auth::Password.errors(input[:password])
|
|
28
|
+
raise GemStack::ValidationError.new(errors: { password: errors }) unless errors.empty?
|
|
29
|
+
|
|
30
|
+
row = GemStack::Auth::Tokens.consume(input[:token], purpose: "password_reset")
|
|
31
|
+
raise GemStack::BadRequest.new("This reset link is invalid or has expired.", code: "invalid_token") unless row
|
|
32
|
+
|
|
33
|
+
user = User[row[:user_id]]
|
|
34
|
+
user.update(password: input[:password])
|
|
35
|
+
# A reset usually means the old password leaked: end every session.
|
|
36
|
+
GemStack::Auth::Sessions.revoke_all(user.id)
|
|
37
|
+
sign_in(user)
|
|
38
|
+
render user
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# POST /api/auth/signup
|
|
4
|
+
class RegistrationsController < ApplicationController
|
|
5
|
+
rate_limit to: 10, within: 3600, only: :create
|
|
6
|
+
|
|
7
|
+
accepts :create do
|
|
8
|
+
required :email, :string, max_length: 254
|
|
9
|
+
required :password, :string, max_length: 1024
|
|
10
|
+
end
|
|
11
|
+
returns :create, UserSerializer
|
|
12
|
+
|
|
13
|
+
def create
|
|
14
|
+
user = User.create(email: input[:email], password: input[:password]) # invalid: 422 with field errors
|
|
15
|
+
AuthMailer.email_verification(user.id).deliver_later
|
|
16
|
+
sign_in(user)
|
|
17
|
+
render user, status: :created
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# POST /api/auth/login · GET /api/auth/me · DELETE /api/auth/logout
|
|
4
|
+
class SessionsController < ApplicationController
|
|
5
|
+
before :require_login, only: :show
|
|
6
|
+
# Per IP, and per account so one address can't be guessed at from many IPs.
|
|
7
|
+
rate_limit to: 20, within: 300, only: :create
|
|
8
|
+
rate_limit to: 10, within: 300, only: :create, name: "login-email", by: -> { params[:email].to_s.downcase }
|
|
9
|
+
|
|
10
|
+
accepts :create do
|
|
11
|
+
required :email, :string, max_length: 254
|
|
12
|
+
required :password, :string, max_length: 1024
|
|
13
|
+
end
|
|
14
|
+
returns :create, UserSerializer
|
|
15
|
+
returns :show, UserSerializer
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
user = User.authenticate_by(email: input[:email], password: input[:password])
|
|
19
|
+
# The same answer for "no such account" and "wrong password".
|
|
20
|
+
raise GemStack::Unauthorized.new("Invalid email or password.", code: "invalid_credentials") unless user
|
|
21
|
+
|
|
22
|
+
sign_in(user)
|
|
23
|
+
render user
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show = render(current_user)
|
|
27
|
+
|
|
28
|
+
def destroy = sign_out
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Account emails. Templates: app/mailers/templates/auth_mailer/*.erb.
|
|
4
|
+
# Links point at the Next.js pages (config.auth.app_url, APP_URL in production).
|
|
5
|
+
class AuthMailer < GemStack::Mailer
|
|
6
|
+
def password_reset(user_id)
|
|
7
|
+
@user = User[user_id] or return
|
|
8
|
+
token, = GemStack::Auth::Tokens.issue(@user.id, purpose: "password_reset")
|
|
9
|
+
@url = GemStack::Auth.url("/reset-password", token: token)
|
|
10
|
+
@minutes = GemStack.config.auth.password_reset_ttl / 60
|
|
11
|
+
mail to: @user.email, subject: "Reset your password"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def email_verification(user_id)
|
|
15
|
+
@user = User[user_id] or return
|
|
16
|
+
token, = GemStack::Auth::Tokens.issue(@user.id, purpose: "email_verification", email: @user.email)
|
|
17
|
+
@url = GemStack::Auth.url("/verify-email", token: token)
|
|
18
|
+
mail to: @user.email, subject: "Confirm your email address"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# API tokens (and single-use reset/verification tokens) — see GemStack::Auth::Tokens.
|
|
4
|
+
class AuthToken < GemStack::Model
|
|
5
|
+
field :purpose, :string, null: false
|
|
6
|
+
field :name, :string, size: 100
|
|
7
|
+
field :last_used_at, :datetime
|
|
8
|
+
field :expires_at, :datetime
|
|
9
|
+
|
|
10
|
+
belongs_to :user
|
|
11
|
+
|
|
12
|
+
dataset_module do
|
|
13
|
+
def api = where(purpose: "api")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Accounts. GemStack::Auth::User adds password hashing (Argon2id), email
|
|
4
|
+
# normalization, validations and User.authenticate_by(email:, password:).
|
|
5
|
+
class User < GemStack::Model
|
|
6
|
+
include GemStack::Auth::User
|
|
7
|
+
|
|
8
|
+
field :email, :string, null: false, size: 254
|
|
9
|
+
field :email_verified_at, :datetime
|
|
10
|
+
|
|
11
|
+
has_many :auth_tokens
|
|
12
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The response to creating an API token: the only time the token itself is shown.
|
|
4
|
+
class NewApiTokenSerializer < GemStack::Serializer
|
|
5
|
+
Result = Data.define(:token, :api_token)
|
|
6
|
+
|
|
7
|
+
attribute :token, :string
|
|
8
|
+
attribute :api_token, ApiTokenSerializer
|
|
9
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tables used by GemStack::Auth (docs/authentication.md).
|
|
4
|
+
Sequel.migration do
|
|
5
|
+
change do
|
|
6
|
+
create_table(:users) do
|
|
7
|
+
primary_key :id, type: :Bignum
|
|
8
|
+
column :email, :text, null: false, unique: true # stored lowercased
|
|
9
|
+
column :password_digest, :text, null: false # Argon2id
|
|
10
|
+
column :email_verified_at, :timestamptz
|
|
11
|
+
column :created_at, :timestamptz, null: false
|
|
12
|
+
column :updated_at, :timestamptz, null: false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# One row per signed-in browser; the cookie holds a random token, the
|
|
16
|
+
# table only its SHA-256 digest.
|
|
17
|
+
create_table(:sessions) do
|
|
18
|
+
primary_key :id, type: :Bignum
|
|
19
|
+
foreign_key :user_id, :users, type: :Bignum, null: false, on_delete: :cascade, index: true
|
|
20
|
+
column :token_digest, :text, null: false, unique: true
|
|
21
|
+
column :ip, :inet
|
|
22
|
+
column :user_agent, :text
|
|
23
|
+
column :created_at, :timestamptz, null: false
|
|
24
|
+
column :last_seen_at, :timestamptz, null: false
|
|
25
|
+
column :expires_at, :timestamptz, null: false, index: true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# API tokens, password reset and email verification tokens (digests only).
|
|
29
|
+
create_table(:auth_tokens) do
|
|
30
|
+
primary_key :id, type: :Bignum
|
|
31
|
+
foreign_key :user_id, :users, type: :Bignum, null: false, on_delete: :cascade, index: true
|
|
32
|
+
column :purpose, :text, null: false
|
|
33
|
+
column :token_digest, :text, null: false, unique: true
|
|
34
|
+
column :name, :text
|
|
35
|
+
column :email, :text
|
|
36
|
+
column :created_at, :timestamptz, null: false
|
|
37
|
+
column :last_used_at, :timestamptz
|
|
38
|
+
column :expires_at, :timestamptz
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useState, type FormEvent } from "react";
|
|
6
|
+
import {
|
|
7
|
+
errorMessage,
|
|
8
|
+
useApiTokens,
|
|
9
|
+
useCreateApiToken,
|
|
10
|
+
useCurrentUser,
|
|
11
|
+
useLogout,
|
|
12
|
+
useResendVerification,
|
|
13
|
+
useRevokeApiToken,
|
|
14
|
+
} from "@/lib/auth";
|
|
15
|
+
|
|
16
|
+
export default function AccountPage() {
|
|
17
|
+
const router = useRouter();
|
|
18
|
+
const { data: user, isPending } = useCurrentUser();
|
|
19
|
+
const logout = useLogout();
|
|
20
|
+
const resend = useResendVerification();
|
|
21
|
+
|
|
22
|
+
if (isPending) return <main className="page muted">Loading…</main>;
|
|
23
|
+
if (!user) {
|
|
24
|
+
return (
|
|
25
|
+
<main className="page">
|
|
26
|
+
<h1>Account</h1>
|
|
27
|
+
<p>
|
|
28
|
+
You are signed out. <Link href="/login">Log in</Link> or <Link href="/signup">sign up</Link>.
|
|
29
|
+
</p>
|
|
30
|
+
</main>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<main className="page">
|
|
36
|
+
<div className="page-header">
|
|
37
|
+
<h1>Account</h1>
|
|
38
|
+
<button className="button" onClick={() => logout.mutate(undefined, { onSuccess: () => router.push("/login") })}>
|
|
39
|
+
Log out
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
<dl className="card">
|
|
43
|
+
<dt>Email</dt>
|
|
44
|
+
<dd>
|
|
45
|
+
{user.email}{" "}
|
|
46
|
+
{user.email_verified_at ? (
|
|
47
|
+
<span className="muted">(confirmed)</span>
|
|
48
|
+
) : (
|
|
49
|
+
<button className="button" disabled={resend.isPending || resend.isSuccess} onClick={() => resend.mutate()}>
|
|
50
|
+
{resend.isSuccess ? "Email sent" : "Resend confirmation email"}
|
|
51
|
+
</button>
|
|
52
|
+
)}
|
|
53
|
+
</dd>
|
|
54
|
+
<dt>Member since</dt>
|
|
55
|
+
<dd>{new Date(user.created_at).toLocaleDateString()}</dd>
|
|
56
|
+
</dl>
|
|
57
|
+
<ApiTokens />
|
|
58
|
+
</main>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ApiTokens() {
|
|
63
|
+
const tokens = useApiTokens();
|
|
64
|
+
const create = useCreateApiToken();
|
|
65
|
+
const revoke = useRevokeApiToken();
|
|
66
|
+
const [name, setName] = useState("");
|
|
67
|
+
|
|
68
|
+
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
create.mutate(name, { onSuccess: () => setName("") });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<section>
|
|
75
|
+
<h2>API tokens</h2>
|
|
76
|
+
<p className="muted">
|
|
77
|
+
For scripts and other services: send <code>Authorization: Bearer <token></code>.
|
|
78
|
+
</p>
|
|
79
|
+
{create.data && (
|
|
80
|
+
<p className="status status-ok">
|
|
81
|
+
<span className="dot" />
|
|
82
|
+
Copy your new token now — it won't be shown again: <code>{create.data.token}</code>
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
85
|
+
<form className="form" onSubmit={handleSubmit}>
|
|
86
|
+
<label className="field">
|
|
87
|
+
Name
|
|
88
|
+
<input required maxLength={100} value={name} onChange={(e) => setName(e.target.value)} placeholder="CI" />
|
|
89
|
+
</label>
|
|
90
|
+
{create.error && <p className="form-error">{errorMessage(create.error)}</p>}
|
|
91
|
+
<div className="actions">
|
|
92
|
+
<button className="button" type="submit" disabled={create.isPending}>
|
|
93
|
+
Create token
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
</form>
|
|
97
|
+
<table className="table">
|
|
98
|
+
<tbody>
|
|
99
|
+
{tokens.data?.map((token) => (
|
|
100
|
+
<tr key={token.id}>
|
|
101
|
+
<td>{token.name}</td>
|
|
102
|
+
<td className="muted">
|
|
103
|
+
{token.last_used_at ? `used ${new Date(token.last_used_at).toLocaleString()}` : "never used"}
|
|
104
|
+
</td>
|
|
105
|
+
<td>
|
|
106
|
+
<button className="button button-danger" onClick={() => revoke.mutate(token.id)}>
|
|
107
|
+
Revoke
|
|
108
|
+
</button>
|
|
109
|
+
</td>
|
|
110
|
+
</tr>
|
|
111
|
+
))}
|
|
112
|
+
</tbody>
|
|
113
|
+
</table>
|
|
114
|
+
</section>
|
|
115
|
+
);
|
|
116
|
+
}
|