wip
Seto Elkahfi committed
Jun 17, 2026 at 22:33 UTC
3d32b7430a614628f6a856abf93c512d9f0a045b
9 files changed
+287
-19
Gemfile
+3
-2
index 5333468..42d6d5d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -19,8 +19,9 @@ gem "tailwindcss-rails", "~> 3.3.1"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
-# smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout
-gem "smbcloud-auth", "~> 0.3.35"
+# smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout.
+# Local path for testing 0.4.4 (reset_password) before it lands on RubyGems.
+gem "smbcloud-auth", path: "../smbcloud-cli/sdk/gems/auth"
# Markdown rendering for README files
gem "redcarpet", "~> 3.6"
Gemfile.lock
+19
-15
index 41a546e..1ca67dc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,10 @@
+PATH
+ remote: ../smbcloud-cli/sdk/gems/auth
+ specs:
+ smbcloud-auth (0.4.4)
+ json
+ rb_sys (~> 0.9.91)
+
GEM
remote: https://rubygems.org/
specs:
@@ -82,9 +89,9 @@ GEM
bcrypt_pbkdf (1.1.2)
bigdecimal (4.1.2)
bindex (0.8.1)
- bootsnap (1.24.5)
+ bootsnap (1.24.6)
msgpack (~> 1.2)
- brakeman (8.0.4)
+ brakeman (8.0.5)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -92,7 +99,7 @@ GEM
thor (~> 1.0)
childprocess (5.1.0)
logger (~> 1.5)
- concurrent-ruby (1.3.6)
+ concurrent-ruby (1.3.7)
connection_pool (3.0.2)
crass (1.0.6)
date (3.5.1)
@@ -115,7 +122,7 @@ GEM
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
- i18n (1.14.8)
+ i18n (1.15.0)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
@@ -133,7 +140,7 @@ GEM
jbuilder (2.15.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
- json (2.19.7)
+ json (2.19.9)
kamal (2.11.0)
activesupport (>= 7.0)
base64 (~> 0.2)
@@ -170,8 +177,8 @@ GEM
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
- msgpack (1.8.1)
- net-imap (0.6.4)
+ msgpack (1.8.3)
+ net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
@@ -202,7 +209,7 @@ GEM
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
- psych (5.3.1)
+ psych (5.4.0)
date
stringio
public_suffix (7.0.5)
@@ -262,7 +269,7 @@ GEM
reline (0.6.3)
io-console (~> 0.5)
rouge (4.7.0)
- rubocop (1.86.2)
+ rubocop (1.88.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -280,7 +287,7 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
- rubocop-rails (2.35.3)
+ rubocop-rails (2.35.4)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -295,9 +302,6 @@ GEM
ffi (~> 1.12)
logger
securerandom (0.4.1)
- smbcloud-auth (0.3.35)
- json
- rb_sys (~> 0.9.91)
solid_cable (4.0.0)
actioncable (>= 7.2)
activejob (>= 7.2)
@@ -346,7 +350,7 @@ GEM
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
- websocket-driver (0.8.0)
+ websocket-driver (0.8.1)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -373,7 +377,7 @@ DEPENDENCIES
redcarpet (~> 3.6)
rouge (~> 4.5)
rubocop-rails-omakase
- smbcloud-auth (~> 0.3.35)
+ smbcloud-auth!
solid_cable
solid_cache
solid_queue
app/controllers/api/v1/passwords_controller.rb
+25
new file mode 100644
index 0000000..615d1ed
--- /dev/null
+++ b/app/controllers/api/v1/passwords_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ # Request password-reset instructions for an account.
+ class PasswordsController < Api::BaseController
+ # POST /api/v1/auth/password/reset
+ # Params: email
+ #
+ # The underlying endpoint always responds the same way regardless of
+ # whether the account exists (no enumeration), and calling it again
+ # re-issues the token, so it also serves the desktop "resend reset
+ # instructions" flow. Returns 204 on success.
+ def create
+ email = params[:email].to_s.strip.downcase
+ if email.blank?
+ return render_error(ERR_INVALID, "Email is required.", status: :unprocessable_entity)
+ end
+
+ SmbcloudAuthService.reset_password(email: email)
+ head :no_content
+ end
+ end
+ end
+end
app/controllers/passwords_controller.rb
+85
new file mode 100644
index 0000000..edb1c16
--- /dev/null
+++ b/app/controllers/passwords_controller.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+class PasswordsController < ApplicationController
+ before_action :redirect_if_signed_in
+
+ # GET /auth/password/reset
+ def new
+ end
+
+ # POST /auth/password/reset
+ def create
+ email = params[:email].to_s.strip.downcase
+
+ if email.blank?
+ flash.now[:alert] = "Email is required."
+ return render :new, status: :unprocessable_entity
+ end
+
+ SmbcloudAuthService.reset_password(email: email)
+
+ # The endpoint never reveals whether the account exists, so we always show
+ # the same neutral message.
+ redirect_to signin_path,
+ notice: "If that email has an account, we've sent password reset instructions. Please check your inbox."
+
+ rescue KeyError => e
+ Rails.logger.error("smbCloud configuration error during reset password: #{e.message}")
+ flash.now[:alert] = "Authentication service is not configured. Please contact support."
+ render :new, status: :internal_server_error
+
+ rescue SmbcloudAuthService::AuthenticationError => e
+ Rails.logger.warn("reset password failed for #{email.inspect}: #{e.message}")
+ flash.now[:alert] = "Something went wrong sending the reset email. Please try again."
+ render :new, status: :internal_server_error
+ end
+
+ # GET /auth/password/edit?reset_password_token=...
+ # Landing page from the reset email (smbCloud redirects here). Shows the form
+ # to choose a new password; the token rides in a hidden field.
+ def edit
+ @reset_password_token = params[:reset_password_token].to_s
+ end
+
+ # POST /auth/password/edit
+ def update
+ @reset_password_token = params[:reset_password_token].to_s
+ password = params[:password].to_s
+ password_confirmation = params[:password_confirmation].to_s
+
+ if @reset_password_token.blank?
+ flash.now[:alert] = "This reset link is missing its token. Please request a new one."
+ return render :edit, status: :unprocessable_entity
+ end
+
+ if password.blank?
+ flash.now[:alert] = "Please enter a new password."
+ return render :edit, status: :unprocessable_entity
+ end
+
+ SmbcloudAuthService.complete_password_reset(
+ reset_password_token: @reset_password_token,
+ password: password,
+ password_confirmation: password_confirmation
+ )
+
+ redirect_to signin_path, notice: "Your password has been reset. Please sign in."
+
+ rescue KeyError => e
+ Rails.logger.error("smbCloud configuration error during reset complete: #{e.message}")
+ flash.now[:alert] = "Authentication service is not configured. Please contact support."
+ render :edit, status: :internal_server_error
+
+ rescue SmbcloudAuthService::AuthenticationError => e
+ Rails.logger.warn("reset password complete failed: #{e.message}")
+ flash.now[:alert] = e.message.presence ||
+ "We couldn't reset your password. The link may have expired — request a new one."
+ render :edit, status: :unprocessable_entity
+ end
+
+ private
+
+ def redirect_if_signed_in
+ redirect_to root_path, notice: "You are already signed in." if signed_in?
+ end
+end
app/services/smbcloud_auth_service.rb
+31
-1
index 8c3f009..8907039 100644
--- a/app/services/smbcloud_auth_service.rb
+++ b/app/services/smbcloud_auth_service.rb
@@ -125,6 +125,31 @@ class SmbcloudAuthService
post_client("v1/client/users/resend_confirmation", user: { email: email })
end
+ # Asks smbCloud to email password-reset instructions for an account.
+ #
+ # Wrapped natively by the gem (>= 0.4.4). Like resend_confirmation, the
+ # endpoint always responds the same way regardless of whether the email
+ # exists (no enumeration), and calling it again re-issues the token, so it
+ # also serves the "resend reset instructions" flow. Returns the gem's
+ # { code:, message: } hash.
+ def self.reset_password(email:)
+ client.reset_password(email: email)
+ rescue SmbCloud::Auth::Error => e
+ raise AuthenticationError.new(e.message, error_code: e.error_code)
+ end
+
+ # Completes a password reset: submits the token (from the email link) and the
+ # new password to smbCloud. Raises AuthenticationError (with the API's message)
+ # if the token is invalid/expired or the password is rejected.
+ def self.complete_password_reset(reset_password_token:, password:, password_confirmation:)
+ post_client(
+ "v1/client/users/reset_password/complete",
+ reset_password_token: reset_password_token,
+ password: password,
+ password_confirmation: password_confirmation
+ )
+ end
+
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
@@ -184,7 +209,12 @@ class SmbcloudAuthService
end
unless response.is_a?(Net::HTTPSuccess)
- raise AuthenticationError.new("smbCloud request failed (HTTP #{response.code}).")
+ # Surface the API's own message (e.g. "Reset password token is invalid")
+ # when present, so callers can show something useful instead of a bare
+ # status code.
+ body = response.body.to_s.empty? ? {} : (JSON.parse(response.body) rescue {})
+ message = body["message"].presence || "smbCloud request failed (HTTP #{response.code})."
+ raise AuthenticationError.new(message, error_code: body["error_code"])
end
response.body.to_s.empty? ? {} : JSON.parse(response.body)
app/views/passwords/edit.html.erb
+56
new file mode 100644
index 0000000..73086de
--- /dev/null
+++ b/app/views/passwords/edit.html.erb
@@ -0,0 +1,56 @@
+<% content_for :title, "Set a new password" %>
+
+<div class="min-h-screen bg-surface-900 flex flex-col items-center justify-center px-4 py-16">
+
+ <div class="mb-10 flex flex-col items-center gap-3">
+ <%= link_to root_path, class: "flex items-center gap-3 text-gray-100 hover:text-brand-500 transition-colors" do %>
+ <img src="/icon.png" alt="siGit" class="h-9 w-auto">
+ <span class="text-xl font-semibold tracking-tight">Code & Deploy</span>
+ <% end %>
+ <p class="text-sm text-gray-500">Choose a new password</p>
+ </div>
+
+ <div class="w-full max-w-sm">
+ <div class="border border-surface-500 rounded bg-surface-700 px-8 py-8">
+
+ <% if flash[:alert].present? %>
+ <div class="mb-5 flex items-start gap-2.5 rounded border border-red-800/40 bg-red-900/20 px-3.5 py-3 text-sm text-red-400" role="alert">
+ <svg class="w-4 h-4 mt-0.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M8 1a7 7 0 1 1 0 14A7 7 0 0 1 8 1zm0 3.75a.75.75 0 0 0-.75.75v3.5a.75.75 0 0 0 1.5 0v-3.5A.75.75 0 0 0 8 4.75zm0 7.5a.875.875 0 1 0 0-1.75.875.875 0 0 0 0 1.75z"/>
+ </svg>
+ <span><%= flash[:alert] %></span>
+ </div>
+ <% end %>
+
+ <%= form_tag edit_password_path, method: :post, class: "space-y-5" do %>
+ <%= hidden_field_tag :reset_password_token, @reset_password_token %>
+
+ <div>
+ <label for="password" class="form-label">New password</label>
+ <input type="password" id="password" name="password"
+ class="form-input" autocomplete="new-password"
+ minlength="8" placeholder="At least 8 characters"
+ autofocus required>
+ </div>
+
+ <div>
+ <label for="password_confirmation" class="form-label">Confirm new password</label>
+ <input type="password" id="password_confirmation" name="password_confirmation"
+ class="form-input" autocomplete="new-password"
+ minlength="8" required>
+ </div>
+
+ <button type="submit" class="btn-primary w-full justify-center py-2.5 mt-2">
+ Set new password
+ </button>
+
+ <% end %>
+ </div>
+
+ <p class="mt-6 text-center text-xs text-gray-500">
+ Back to
+ <%= link_to "Sign in", signin_path, class: "text-gray-400 hover:text-gray-200 underline underline-offset-2" %>
+ </p>
+ </div>
+
+</div>
app/views/passwords/new.html.erb
+54
new file mode 100644
index 0000000..17edd84
--- /dev/null
+++ b/app/views/passwords/new.html.erb
@@ -0,0 +1,54 @@
+<% content_for :title, "Reset password" %>
+
+<div class="min-h-screen bg-surface-900 flex flex-col items-center justify-center px-4 py-16">
+
+ <div class="mb-10 flex flex-col items-center gap-3">
+ <%= link_to root_path, class: "flex items-center gap-3 text-gray-100 hover:text-brand-500 transition-colors" do %>
+ <img src="/icon.png" alt="siGit" class="h-9 w-auto">
+ <span class="text-xl font-semibold tracking-tight">Code & Deploy</span>
+ <% end %>
+ <p class="text-sm text-gray-500">Reset your password</p>
+ </div>
+
+ <div class="w-full max-w-sm">
+ <div class="border border-surface-500 rounded bg-surface-700 px-8 py-8">
+
+ <% if flash[:alert].present? %>
+ <div class="mb-5 flex items-start gap-2.5 rounded border border-red-800/40 bg-red-900/20 px-3.5 py-3 text-sm text-red-400" role="alert">
+ <svg class="w-4 h-4 mt-0.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M8 1a7 7 0 1 1 0 14A7 7 0 0 1 8 1zm0 3.75a.75.75 0 0 0-.75.75v3.5a.75.75 0 0 0 1.5 0v-3.5A.75.75 0 0 0 8 4.75zm0 7.5a.875.875 0 1 0 0-1.75.875.875 0 0 0 0 1.75z"/>
+ </svg>
+ <span><%= flash[:alert] %></span>
+ </div>
+ <% end %>
+
+ <p class="mb-5 text-sm text-gray-400 leading-relaxed">
+ Enter your email and we'll send a link to reset your password if an
+ account exists for it.
+ </p>
+
+ <%= form_tag reset_password_path, method: :post, class: "space-y-5" do %>
+
+ <div>
+ <label for="email" class="form-label">Email</label>
+ <input type="email" id="email" name="email"
+ class="form-input" autocomplete="email"
+ value="<%= params[:email].to_s %>"
+ placeholder="you@example.com"
+ autofocus required>
+ </div>
+
+ <button type="submit" class="btn-primary w-full justify-center py-2.5 mt-2">
+ Send reset instructions
+ </button>
+
+ <% end %>
+ </div>
+
+ <p class="mt-6 text-center text-xs text-gray-500">
+ Remembered it?
+ <%= link_to "Sign in", signin_path, class: "text-gray-400 hover:text-gray-200 underline underline-offset-2" %>
+ </p>
+ </div>
+
+</div>
app/views/sessions/new.html.erb
+4
-1
index 33497a0..655c946 100644
--- a/app/views/sessions/new.html.erb
+++ b/app/views/sessions/new.html.erb
@@ -34,7 +34,10 @@
</div>
<div>
- <label for="password" class="form-label">Password</label>
+ <div class="flex items-center justify-between mb-1">
+ <label for="password" class="form-label mb-0">Password</label>
+ <%= link_to "Forgot password?", reset_password_path, class: "text-xs text-gray-400 hover:text-gray-200 underline underline-offset-2" %>
+ </div>
<input type="password" id="password" name="password"
class="form-input" autocomplete="current-password"
required>
config/routes.rb
+10
index 6367a16..9146b93 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -33,6 +33,15 @@ Rails.application.routes.draw do
get "/auth/confirmation/resend", to: "confirmations#new", as: :resend_confirmation
post "/auth/confirmation/resend", to: "confirmations#create"
+ # Reset password — request reset instructions by email
+ get "/auth/password/reset", to: "passwords#new", as: :reset_password
+ post "/auth/password/reset", to: "passwords#create"
+
+ # Reset password — set a new password from the email link
+ # (smbCloud redirects here with ?reset_password_token=...)
+ get "/auth/password/edit", to: "passwords#edit", as: :edit_password
+ post "/auth/password/edit", to: "passwords#update"
+
# Discovery surfaces — declared before the "/:username" matcher so they
# aren't read as profiles.
get "/models", to: "models#index", as: :models # open-weights model library
@@ -55,6 +64,7 @@ Rails.application.routes.draw do
delete "auth/sign_out", to: "sessions#destroy"
post "auth/sign_up", to: "registrations#create"
post "auth/confirmation/resend", to: "confirmations#create"
+ post "auth/password/reset", to: "passwords#create"
get "me", to: "me#show"
delete "me", to: "me#destroy"
get "repos", to: "repos#index"