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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +24 -0
  5. data/exe/gemstack +6 -0
  6. data/lib/gemstack/cli/add_generator.rb +237 -0
  7. data/lib/gemstack/cli/app_generator.rb +110 -0
  8. data/lib/gemstack/cli/commands/db.rb +134 -0
  9. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  10. data/lib/gemstack/cli/controller_generator.rb +73 -0
  11. data/lib/gemstack/cli/deploy_generator.rb +64 -0
  12. data/lib/gemstack/cli/doctor.rb +277 -0
  13. data/lib/gemstack/cli/generator.rb +122 -0
  14. data/lib/gemstack/cli/job_generator.rb +47 -0
  15. data/lib/gemstack/cli/migration_generator.rb +52 -0
  16. data/lib/gemstack/cli/policy_generator.rb +35 -0
  17. data/lib/gemstack/cli/project.rb +53 -0
  18. data/lib/gemstack/cli/resource_generator.rb +169 -0
  19. data/lib/gemstack/cli/resource_spec.rb +95 -0
  20. data/lib/gemstack/cli.rb +299 -0
  21. data/templates/app/Gemfile.tt +39 -0
  22. data/templates/app/README.md.tt +29 -0
  23. data/templates/app/app/controllers/application_controller.rb +6 -0
  24. data/templates/app/bin/gemstack +7 -0
  25. data/templates/app/config/app.rb.tt +30 -0
  26. data/templates/app/config/environments/development.rb +6 -0
  27. data/templates/app/config/environments/production.rb +8 -0
  28. data/templates/app/config/environments/test.rb +7 -0
  29. data/templates/app/config/puma.rb +21 -0
  30. data/templates/app/config/routes.rb +10 -0
  31. data/templates/app/config.ru +6 -0
  32. data/templates/app/db/migrations/dot_keep +0 -0
  33. data/templates/app/db/seeds.rb +5 -0
  34. data/templates/app/dot_env.example +9 -0
  35. data/templates/app/dot_gitignore +14 -0
  36. data/templates/app/dot_ruby-version.tt +1 -0
  37. data/templates/app/dot_tool-versions.tt +1 -0
  38. data/templates/app/test/health_test.rb +18 -0
  39. data/templates/app/test/test_helper.rb.tt +25 -0
  40. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  41. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  42. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  43. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  44. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  45. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  46. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  47. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  48. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  49. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  50. data/templates/auth/app/models/auth_token.rb +15 -0
  51. data/templates/auth/app/models/user.rb +12 -0
  52. data/templates/auth/app/policies/application_policy.rb +6 -0
  53. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  54. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  55. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  56. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  57. data/templates/auth/frontend/app/account/page.tsx +116 -0
  58. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  59. data/templates/auth/frontend/app/login/page.tsx +26 -0
  60. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  61. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  62. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  63. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  64. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  65. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  66. data/templates/auth/frontend/lib/auth.ts +113 -0
  67. data/templates/auth/test/controllers/auth_test.rb +108 -0
  68. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  69. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  70. data/templates/deploy/Caddyfile.tt +19 -0
  71. data/templates/deploy/Dockerfile.tt +62 -0
  72. data/templates/deploy/Procfile.tt +6 -0
  73. data/templates/deploy/compose.yaml.tt +80 -0
  74. data/templates/deploy/dot_dockerignore +14 -0
  75. data/templates/frontend/app/globals.css +171 -0
  76. data/templates/frontend/app/layout.tsx.tt +19 -0
  77. data/templates/frontend/app/page.tsx.tt +45 -0
  78. data/templates/frontend/app/providers.tsx +24 -0
  79. data/templates/frontend/lib/gemstack/client.ts +125 -0
  80. data/templates/frontend/next-env.d.ts +5 -0
  81. data/templates/frontend/next.config.ts +17 -0
  82. data/templates/frontend/package.json.tt +23 -0
  83. data/templates/frontend/tsconfig.json +21 -0
  84. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  85. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  86. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  87. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  88. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  89. data/templates/realtime/config/channels.rb +14 -0
  90. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  91. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  92. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  93. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  94. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  95. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  96. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  97. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  98. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  99. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  100. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  101. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
  102. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  103. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  104. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  105. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  106. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  107. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  108. data/templates/storage/frontend/lib/upload.ts +52 -0
  109. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  110. 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared behaviour for every controller: before callbacks (e.g. authentication),
4
+ # rescue_from handlers and helpers.
5
+ class ApplicationController < GemStack::Controller
6
+ end
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Runs the GemStack CLI from this application's bundle.
5
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
6
+ require "bundler/setup"
7
+ load Gem.bin_path("gemstack-cli", "gemstack")
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Development-only settings. config/app.rb applies to every environment.
4
+ GemStack.configure do |config|
5
+ # config.http.show_exceptions = true # exception details in 500 responses (default in development)
6
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Test-only settings. config/app.rb applies to every environment.
4
+ GemStack.configure do |config|
5
+ # Logs are discarded in tests unless GEMSTACK_LOG_LEVEL is set, e.g.:
6
+ # config.logger.level = :debug
7
+ 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rack entry point. Works with Puma (config/puma.rb) or any Rack server.
4
+ require_relative "config/app"
5
+
6
+ run GemStack.boot!
File without changes
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Development data, loaded by `gemstack db:seed` (and db:setup). Make it safe to run twice:
4
+ #
5
+ # Product.find_by(name: "Desk lamp") || Product.create(name: "Desk lamp", price: "39.00")
@@ -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,14 @@
1
+ /.bundle/
2
+ /log/
3
+ /tmp/
4
+ .env
5
+ .env.local
6
+ .env.*.local
7
+
8
+ node_modules/
9
+ /frontend/.next/
10
+ /frontend/out/
11
+ /frontend/next-env.d.ts
12
+ *.tsbuildinfo
13
+
14
+ .DS_Store
@@ -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,2 @@
1
+ <p>Welcome! Please confirm that <%= @user.email %> is your email address.</p>
2
+ <p><a href="<%= @url %>">Confirm my email address</a></p>
@@ -0,0 +1,2 @@
1
+ Welcome! Please confirm that <%= @user.email %> is your email address:
2
+ <%= @url %>
@@ -0,0 +1,3 @@
1
+ <p>Someone (hopefully you) asked to reset the password for <%= @user.email %>.</p>
2
+ <p><a href="<%= @url %>">Choose a new password</a> — the link works once, for <%= @minutes %> minutes.</p>
3
+ <p>If it wasn't you, ignore this email: your password stays the same.</p>
@@ -0,0 +1,6 @@
1
+ Someone (hopefully you) asked to reset the password for <%= @user.email %>.
2
+
3
+ Choose a new password here (the link works once, for <%= @minutes %> minutes):
4
+ <%= @url %>
5
+
6
+ If it wasn't you, ignore this email — your password stays the same.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for policies (`gemstack generate policy Order`). Everything is
4
+ # denied unless a policy allows it; see docs/authorization.md.
5
+ class ApplicationPolicy < GemStack::Policy
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApiTokenSerializer < GemStack::Serializer
4
+ model AuthToken
5
+ attributes :id, :name, :last_used_at, :expires_at, :created_at
6
+ 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What the API shows about the signed-in user. Never add password_digest.
4
+ class UserSerializer < GemStack::Serializer
5
+ attributes :id, :email, :email_verified_at, :created_at
6
+ 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 &lt;token&gt;</code>.
78
+ </p>
79
+ {create.data && (
80
+ <p className="status status-ok">
81
+ <span className="dot" />
82
+ Copy your new token now — it won&apos;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
+ }