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