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 &amp; 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 &amp; 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"