wip

Seto Elkahfi committed Jun 17, 2026 at 22:33 UTC 3d32b7430a614628f6a856abf93c512d9f0a045b
9 files changed +287 -19
Gemfile
+3 -2
@@ -19,8 +19,9 @@ gem "tailwindcss-rails", "~> 3.3.1"
19 # Build JSON APIs with ease [https://github.com/rails/jbuilder]
20 gem "jbuilder"
21
22 -# smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout
23 -gem "smbcloud-auth", "~> 0.3.35"
22 +# smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout.
23 +# Local path for testing 0.4.4 (reset_password) before it lands on RubyGems.
24 +gem "smbcloud-auth", path: "../smbcloud-cli/sdk/gems/auth"
25
26 # Markdown rendering for README files
27 gem "redcarpet", "~> 3.6"
Gemfile.lock
+19 -15
@@ -1,3 +1,10 @@
1 +PATH
2 + remote: ../smbcloud-cli/sdk/gems/auth
3 + specs:
4 + smbcloud-auth (0.4.4)
5 + json
6 + rb_sys (~> 0.9.91)
7 +
8 GEM
9 remote: https://rubygems.org/
10 specs:
@@ -82,9 +89,9 @@ GEM
89 bcrypt_pbkdf (1.1.2)
90 bigdecimal (4.1.2)
91 bindex (0.8.1)
85 - bootsnap (1.24.5)
92 + bootsnap (1.24.6)
93 msgpack (~> 1.2)
87 - brakeman (8.0.4)
94 + brakeman (8.0.5)
95 racc
96 builder (3.3.0)
97 bundler-audit (0.9.3)
@@ -92,7 +99,7 @@ GEM
99 thor (~> 1.0)
100 childprocess (5.1.0)
101 logger (~> 1.5)
95 - concurrent-ruby (1.3.6)
102 + concurrent-ruby (1.3.7)
103 connection_pool (3.0.2)
104 crass (1.0.6)
105 date (3.5.1)
@@ -115,7 +122,7 @@ GEM
122 raabro (~> 1.4)
123 globalid (1.3.0)
124 activesupport (>= 6.1)
118 - i18n (1.14.8)
125 + i18n (1.15.0)
126 concurrent-ruby (~> 1.0)
127 image_processing (1.14.0)
128 mini_magick (>= 4.9.5, < 6)
@@ -133,7 +140,7 @@ GEM
140 jbuilder (2.15.1)
141 actionview (>= 7.0.0)
142 activesupport (>= 7.0.0)
136 - json (2.19.7)
143 + json (2.19.9)
144 kamal (2.11.0)
145 activesupport (>= 7.0)
146 base64 (~> 0.2)
@@ -170,8 +177,8 @@ GEM
177 minitest (6.0.6)
178 drb (~> 2.0)
179 prism (~> 1.5)
173 - msgpack (1.8.1)
174 - net-imap (0.6.4)
180 + msgpack (1.8.3)
181 + net-imap (0.6.4.1)
182 date
183 net-protocol
184 net-pop (0.1.2)
@@ -202,7 +209,7 @@ GEM
209 actionpack (>= 7.0.0)
210 activesupport (>= 7.0.0)
211 rack
205 - psych (5.3.1)
212 + psych (5.4.0)
213 date
214 stringio
215 public_suffix (7.0.5)
@@ -262,7 +269,7 @@ GEM
269 reline (0.6.3)
270 io-console (~> 0.5)
271 rouge (4.7.0)
265 - rubocop (1.86.2)
272 + rubocop (1.88.0)
273 json (~> 2.3)
274 language_server-protocol (~> 3.17.0.2)
275 lint_roller (~> 1.1.0)
@@ -280,7 +287,7 @@ GEM
287 lint_roller (~> 1.1)
288 rubocop (>= 1.75.0, < 2.0)
289 rubocop-ast (>= 1.47.1, < 2.0)
283 - rubocop-rails (2.35.3)
290 + rubocop-rails (2.35.4)
291 activesupport (>= 4.2.0)
292 lint_roller (~> 1.1)
293 rack (>= 1.1)
@@ -295,9 +302,6 @@ GEM
302 ffi (~> 1.12)
303 logger
304 securerandom (0.4.1)
298 - smbcloud-auth (0.3.35)
299 - json
300 - rb_sys (~> 0.9.91)
305 solid_cable (4.0.0)
306 actioncable (>= 7.2)
307 activejob (>= 7.2)
@@ -346,7 +350,7 @@ GEM
350 actionview (>= 8.0.0)
351 bindex (>= 0.4.0)
352 railties (>= 8.0.0)
349 - websocket-driver (0.8.0)
353 + websocket-driver (0.8.1)
354 base64
355 websocket-extensions (>= 0.1.0)
356 websocket-extensions (0.1.5)
@@ -373,7 +377,7 @@ DEPENDENCIES
377 redcarpet (~> 3.6)
378 rouge (~> 4.5)
379 rubocop-rails-omakase
376 - smbcloud-auth (~> 0.3.35)
380 + smbcloud-auth!
381 solid_cable
382 solid_cache
383 solid_queue
app/controllers/api/v1/passwords_controller.rb new
+25
@@ -0,0 +1,25 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + module V1
5 + # Request password-reset instructions for an account.
6 + class PasswordsController < Api::BaseController
7 + # POST /api/v1/auth/password/reset
8 + # Params: email
9 + #
10 + # The underlying endpoint always responds the same way regardless of
11 + # whether the account exists (no enumeration), and calling it again
12 + # re-issues the token, so it also serves the desktop "resend reset
13 + # instructions" flow. Returns 204 on success.
14 + def create
15 + email = params[:email].to_s.strip.downcase
16 + if email.blank?
17 + return render_error(ERR_INVALID, "Email is required.", status: :unprocessable_entity)
18 + end
19 +
20 + SmbcloudAuthService.reset_password(email: email)
21 + head :no_content
22 + end
23 + end
24 + end
25 +end
app/controllers/passwords_controller.rb new
+85
@@ -0,0 +1,85 @@
1 +# frozen_string_literal: true
2 +
3 +class PasswordsController < ApplicationController
4 + before_action :redirect_if_signed_in
5 +
6 + # GET /auth/password/reset
7 + def new
8 + end
9 +
10 + # POST /auth/password/reset
11 + def create
12 + email = params[:email].to_s.strip.downcase
13 +
14 + if email.blank?
15 + flash.now[:alert] = "Email is required."
16 + return render :new, status: :unprocessable_entity
17 + end
18 +
19 + SmbcloudAuthService.reset_password(email: email)
20 +
21 + # The endpoint never reveals whether the account exists, so we always show
22 + # the same neutral message.
23 + redirect_to signin_path,
24 + notice: "If that email has an account, we've sent password reset instructions. Please check your inbox."
25 +
26 + rescue KeyError => e
27 + Rails.logger.error("smbCloud configuration error during reset password: #{e.message}")
28 + flash.now[:alert] = "Authentication service is not configured. Please contact support."
29 + render :new, status: :internal_server_error
30 +
31 + rescue SmbcloudAuthService::AuthenticationError => e
32 + Rails.logger.warn("reset password failed for #{email.inspect}: #{e.message}")
33 + flash.now[:alert] = "Something went wrong sending the reset email. Please try again."
34 + render :new, status: :internal_server_error
35 + end
36 +
37 + # GET /auth/password/edit?reset_password_token=...
38 + # Landing page from the reset email (smbCloud redirects here). Shows the form
39 + # to choose a new password; the token rides in a hidden field.
40 + def edit
41 + @reset_password_token = params[:reset_password_token].to_s
42 + end
43 +
44 + # POST /auth/password/edit
45 + def update
46 + @reset_password_token = params[:reset_password_token].to_s
47 + password = params[:password].to_s
48 + password_confirmation = params[:password_confirmation].to_s
49 +
50 + if @reset_password_token.blank?
51 + flash.now[:alert] = "This reset link is missing its token. Please request a new one."
52 + return render :edit, status: :unprocessable_entity
53 + end
54 +
55 + if password.blank?
56 + flash.now[:alert] = "Please enter a new password."
57 + return render :edit, status: :unprocessable_entity
58 + end
59 +
60 + SmbcloudAuthService.complete_password_reset(
61 + reset_password_token: @reset_password_token,
62 + password: password,
63 + password_confirmation: password_confirmation
64 + )
65 +
66 + redirect_to signin_path, notice: "Your password has been reset. Please sign in."
67 +
68 + rescue KeyError => e
69 + Rails.logger.error("smbCloud configuration error during reset complete: #{e.message}")
70 + flash.now[:alert] = "Authentication service is not configured. Please contact support."
71 + render :edit, status: :internal_server_error
72 +
73 + rescue SmbcloudAuthService::AuthenticationError => e
74 + Rails.logger.warn("reset password complete failed: #{e.message}")
75 + flash.now[:alert] = e.message.presence ||
76 + "We couldn't reset your password. The link may have expired — request a new one."
77 + render :edit, status: :unprocessable_entity
78 + end
79 +
80 + private
81 +
82 + def redirect_if_signed_in
83 + redirect_to root_path, notice: "You are already signed in." if signed_in?
84 + end
85 +end
app/services/smbcloud_auth_service.rb
+31 -1
@@ -125,6 +125,31 @@ class SmbcloudAuthService
125 post_client("v1/client/users/resend_confirmation", user: { email: email })
126 end
127
128 + # Asks smbCloud to email password-reset instructions for an account.
129 + #
130 + # Wrapped natively by the gem (>= 0.4.4). Like resend_confirmation, the
131 + # endpoint always responds the same way regardless of whether the email
132 + # exists (no enumeration), and calling it again re-issues the token, so it
133 + # also serves the "resend reset instructions" flow. Returns the gem's
134 + # { code:, message: } hash.
135 + def self.reset_password(email:)
136 + client.reset_password(email: email)
137 + rescue SmbCloud::Auth::Error => e
138 + raise AuthenticationError.new(e.message, error_code: e.error_code)
139 + end
140 +
141 + # Completes a password reset: submits the token (from the email link) and the
142 + # new password to smbCloud. Raises AuthenticationError (with the API's message)
143 + # if the token is invalid/expired or the password is rejected.
144 + def self.complete_password_reset(reset_password_token:, password:, password_confirmation:)
145 + post_client(
146 + "v1/client/users/reset_password/complete",
147 + reset_password_token: reset_password_token,
148 + password: password,
149 + password_confirmation: password_confirmation
150 + )
151 + end
152 +
153 # ---------------------------------------------------------------------------
154 # Private helpers
155 # ---------------------------------------------------------------------------
@@ -184,7 +209,12 @@ class SmbcloudAuthService
209 end
210
211 unless response.is_a?(Net::HTTPSuccess)
187 - raise AuthenticationError.new("smbCloud request failed (HTTP #{response.code}).")
212 + # Surface the API's own message (e.g. "Reset password token is invalid")
213 + # when present, so callers can show something useful instead of a bare
214 + # status code.
215 + body = response.body.to_s.empty? ? {} : (JSON.parse(response.body) rescue {})
216 + message = body["message"].presence || "smbCloud request failed (HTTP #{response.code})."
217 + raise AuthenticationError.new(message, error_code: body["error_code"])
218 end
219
220 response.body.to_s.empty? ? {} : JSON.parse(response.body)
app/views/passwords/edit.html.erb new
+56
@@ -0,0 +1,56 @@
1 +<% content_for :title, "Set a new password" %>
2 +
3 +<div class="min-h-screen bg-surface-900 flex flex-col items-center justify-center px-4 py-16">
4 +
5 + <div class="mb-10 flex flex-col items-center gap-3">
6 + <%= link_to root_path, class: "flex items-center gap-3 text-gray-100 hover:text-brand-500 transition-colors" do %>
7 + <img src="/icon.png" alt="siGit" class="h-9 w-auto">
8 + <span class="text-xl font-semibold tracking-tight">Code &amp; Deploy</span>
9 + <% end %>
10 + <p class="text-sm text-gray-500">Choose a new password</p>
11 + </div>
12 +
13 + <div class="w-full max-w-sm">
14 + <div class="border border-surface-500 rounded bg-surface-700 px-8 py-8">
15 +
16 + <% if flash[:alert].present? %>
17 + <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">
18 + <svg class="w-4 h-4 mt-0.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
19 + <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"/>
20 + </svg>
21 + <span><%= flash[:alert] %></span>
22 + </div>
23 + <% end %>
24 +
25 + <%= form_tag edit_password_path, method: :post, class: "space-y-5" do %>
26 + <%= hidden_field_tag :reset_password_token, @reset_password_token %>
27 +
28 + <div>
29 + <label for="password" class="form-label">New password</label>
30 + <input type="password" id="password" name="password"
31 + class="form-input" autocomplete="new-password"
32 + minlength="8" placeholder="At least 8 characters"
33 + autofocus required>
34 + </div>
35 +
36 + <div>
37 + <label for="password_confirmation" class="form-label">Confirm new password</label>
38 + <input type="password" id="password_confirmation" name="password_confirmation"
39 + class="form-input" autocomplete="new-password"
40 + minlength="8" required>
41 + </div>
42 +
43 + <button type="submit" class="btn-primary w-full justify-center py-2.5 mt-2">
44 + Set new password
45 + </button>
46 +
47 + <% end %>
48 + </div>
49 +
50 + <p class="mt-6 text-center text-xs text-gray-500">
51 + Back to
52 + <%= link_to "Sign in", signin_path, class: "text-gray-400 hover:text-gray-200 underline underline-offset-2" %>
53 + </p>
54 + </div>
55 +
56 +</div>
app/views/passwords/new.html.erb new
+54
@@ -0,0 +1,54 @@
1 +<% content_for :title, "Reset password" %>
2 +
3 +<div class="min-h-screen bg-surface-900 flex flex-col items-center justify-center px-4 py-16">
4 +
5 + <div class="mb-10 flex flex-col items-center gap-3">
6 + <%= link_to root_path, class: "flex items-center gap-3 text-gray-100 hover:text-brand-500 transition-colors" do %>
7 + <img src="/icon.png" alt="siGit" class="h-9 w-auto">
8 + <span class="text-xl font-semibold tracking-tight">Code &amp; Deploy</span>
9 + <% end %>
10 + <p class="text-sm text-gray-500">Reset your password</p>
11 + </div>
12 +
13 + <div class="w-full max-w-sm">
14 + <div class="border border-surface-500 rounded bg-surface-700 px-8 py-8">
15 +
16 + <% if flash[:alert].present? %>
17 + <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">
18 + <svg class="w-4 h-4 mt-0.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
19 + <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"/>
20 + </svg>
21 + <span><%= flash[:alert] %></span>
22 + </div>
23 + <% end %>
24 +
25 + <p class="mb-5 text-sm text-gray-400 leading-relaxed">
26 + Enter your email and we'll send a link to reset your password if an
27 + account exists for it.
28 + </p>
29 +
30 + <%= form_tag reset_password_path, method: :post, class: "space-y-5" do %>
31 +
32 + <div>
33 + <label for="email" class="form-label">Email</label>
34 + <input type="email" id="email" name="email"
35 + class="form-input" autocomplete="email"
36 + value="<%= params[:email].to_s %>"
37 + placeholder="you@example.com"
38 + autofocus required>
39 + </div>
40 +
41 + <button type="submit" class="btn-primary w-full justify-center py-2.5 mt-2">
42 + Send reset instructions
43 + </button>
44 +
45 + <% end %>
46 + </div>
47 +
48 + <p class="mt-6 text-center text-xs text-gray-500">
49 + Remembered it?
50 + <%= link_to "Sign in", signin_path, class: "text-gray-400 hover:text-gray-200 underline underline-offset-2" %>
51 + </p>
52 + </div>
53 +
54 +</div>
app/views/sessions/new.html.erb
+4 -1
@@ -34,7 +34,10 @@
34 </div>
35
36 <div>
37 - <label for="password" class="form-label">Password</label>
37 + <div class="flex items-center justify-between mb-1">
38 + <label for="password" class="form-label mb-0">Password</label>
39 + <%= link_to "Forgot password?", reset_password_path, class: "text-xs text-gray-400 hover:text-gray-200 underline underline-offset-2" %>
40 + </div>
41 <input type="password" id="password" name="password"
42 class="form-input" autocomplete="current-password"
43 required>
config/routes.rb
+10
@@ -33,6 +33,15 @@ Rails.application.routes.draw do
33 get "/auth/confirmation/resend", to: "confirmations#new", as: :resend_confirmation
34 post "/auth/confirmation/resend", to: "confirmations#create"
35
36 + # Reset password — request reset instructions by email
37 + get "/auth/password/reset", to: "passwords#new", as: :reset_password
38 + post "/auth/password/reset", to: "passwords#create"
39 +
40 + # Reset password — set a new password from the email link
41 + # (smbCloud redirects here with ?reset_password_token=...)
42 + get "/auth/password/edit", to: "passwords#edit", as: :edit_password
43 + post "/auth/password/edit", to: "passwords#update"
44 +
45 # Discovery surfaces — declared before the "/:username" matcher so they
46 # aren't read as profiles.
47 get "/models", to: "models#index", as: :models # open-weights model library
@@ -55,6 +64,7 @@ Rails.application.routes.draw do
64 delete "auth/sign_out", to: "sessions#destroy"
65 post "auth/sign_up", to: "registrations#create"
66 post "auth/confirmation/resend", to: "confirmations#create"
67 + post "auth/password/reset", to: "passwords#create"
68 get "me", to: "me#show"
69 delete "me", to: "me#destroy"
70 get "repos", to: "repos#index"