Add resend confirmation

Seto Elkahfi committed Jun 17, 2026 at 07:30 UTC c78b320d9a9610039bf3a4ce03fe5f5d956c33b4
7 files changed +174 -8
app/controllers/confirmations_controller.rb new
+42
@@ -0,0 +1,42 @@
1 +# frozen_string_literal: true
2 +
3 +class ConfirmationsController < ApplicationController
4 + before_action :redirect_if_signed_in
5 +
6 + # GET /auth/confirmation/resend
7 + def new
8 + end
9 +
10 + # POST /auth/confirmation/resend
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.resend_confirmation(email: email)
20 +
21 + # The endpoint never reveals whether the account exists or is already
22 + # confirmed, so we always show the same neutral message.
23 + redirect_to signin_path,
24 + notice: "If that email has an unconfirmed account, we've sent a new confirmation link. Please check your inbox."
25 +
26 + rescue KeyError => e
27 + Rails.logger.error("smbCloud configuration error during resend confirmation: #{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("resend confirmation failed for #{email.inspect}: #{e.message}")
33 + flash.now[:alert] = "Something went wrong sending the confirmation email. Please try again."
34 + render :new, status: :internal_server_error
35 + end
36 +
37 + private
38 +
39 + def redirect_if_signed_in
40 + redirect_to root_path, notice: "You are already signed in." if signed_in?
41 + end
42 +end
app/services/smbcloud_auth_service.rb
+66 -5
@@ -24,6 +24,8 @@
24 # SIGITSI_SMBCLOUD_ENVIRONMENT – "production" (default) or "dev"
25 #
26 require "auth"
27 +require "net/http"
28 +require "json"
29
30 class SmbcloudAuthService
31 class AuthenticationError < StandardError
@@ -113,11 +115,23 @@ class SmbcloudAuthService
115 raise AuthenticationError.new(e.message, error_code: e.error_code)
116 end
117
118 + # Asks smbCloud to resend the confirmation email for an unconfirmed account.
119 + #
120 + # The gem does not wrap this endpoint, so we call it directly. The endpoint
121 + # intentionally always returns 200 with a neutral message (no account
122 + # enumeration), so this returns the parsed body and never reveals whether the
123 + # email belongs to a real or still-unconfirmed account.
124 + def self.resend_confirmation(email:)
125 + post_client("v1/client/users/resend_confirmation", user: { email: email })
126 + end
127 +
128 # ---------------------------------------------------------------------------
129 # Private helpers
130 # ---------------------------------------------------------------------------
131
120 - def self.smbcloud_environment
132 + # Resolves the configured environment to :dev or :production.
133 + # In development the dev panel can override via Rails.cache.
134 + def self.resolved_environment_name
135 raw = if Rails.env.development?
136 Rails.cache.read("dev:smbcloud_environment") ||
137 ENV.fetch("SIGITSI_SMBCLOUD_ENVIRONMENT", "dev")
@@ -126,12 +140,58 @@ class SmbcloudAuthService
140 end.strip.downcase
141
142 case raw
129 - when "dev", "development" then SmbCloud::Auth::Environment::DEV
130 - when "production" then SmbCloud::Auth::Environment::PRODUCTION
143 + when "dev", "development" then :dev
144 + when "production" then :production
145 else
146 Rails.logger.warn("Unknown SIGITSI_SMBCLOUD_ENVIRONMENT '#{raw}', defaulting to production.")
133 - SmbCloud::Auth::Environment::PRODUCTION
147 + :production
148 + end
149 + end
150 +
151 + def self.smbcloud_environment
152 + case resolved_environment_name
153 + when :dev then SmbCloud::Auth::Environment::DEV
154 + else SmbCloud::Auth::Environment::PRODUCTION
155 + end
156 + end
157 +
158 + # Base URL for direct HTTP calls to tenant endpoints the gem doesn't wrap.
159 + # Mirrors the gem's Environment hosts (dev: localhost:8088, prod: api.smbcloud.xyz).
160 + def self.smbcloud_base_url
161 + case resolved_environment_name
162 + when :dev then "http://localhost:8088"
163 + else "https://api.smbcloud.xyz"
164 + end
165 + end
166 +
167 + # POSTs a JSON payload to a tenant client endpoint, authenticating with the
168 + # app's client_id/client_secret query params (the same shape the gem uses).
169 + # Returns the parsed JSON body, or {} when the body is empty.
170 + def self.post_client(path, payload)
171 + uri = URI.parse("#{smbcloud_base_url}/#{path}")
172 + uri.query = URI.encode_www_form(
173 + client_id: smbcloud_app_id,
174 + client_secret: smbcloud_app_secret
175 + )
176 +
177 + request = Net::HTTP::Post.new(uri)
178 + request["Content-Type"] = "application/json"
179 + request["User-Agent"] = smbcloud_app_id
180 + request.body = payload.to_json
181 +
182 + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
183 + http.request(request)
184 end
185 +
186 + unless response.is_a?(Net::HTTPSuccess)
187 + raise AuthenticationError.new("smbCloud request failed (HTTP #{response.code}).")
188 + end
189 +
190 + response.body.to_s.empty? ? {} : JSON.parse(response.body)
191 + rescue AuthenticationError, KeyError
192 + raise
193 + rescue StandardError => e
194 + raise AuthenticationError.new(e.message)
195 end
196
197 def self.smbcloud_app_id
@@ -146,5 +206,6 @@ class SmbcloudAuthService
206 end
207 end
208
149 - private_class_method :client, :smbcloud_environment, :smbcloud_app_id, :smbcloud_app_secret
209 + private_class_method :client, :resolved_environment_name, :smbcloud_environment,
210 + :smbcloud_base_url, :post_client, :smbcloud_app_id, :smbcloud_app_secret
211 end
app/views/confirmations/new.html.erb new
+54
@@ -0,0 +1,54 @@
1 +<% content_for :title, "Resend confirmation" %>
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">Resend confirmation email</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 new confirmation link if your account
27 + hasn't been verified yet.
28 + </p>
29 +
30 + <%= form_tag resend_confirmation_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 + Resend confirmation email
43 + </button>
44 +
45 + <% end %>
46 + </div>
47 +
48 + <p class="mt-6 text-center text-xs text-gray-500">
49 + Back to
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/registrations/new.html.erb
+1 -1
@@ -5,7 +5,7 @@
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">siGit Code &amp; Deploy</span>
8 + <span class="text-xl font-semibold tracking-tight">Code &amp; Deploy</span>
9 <% end %>
10 <p class="text-sm text-gray-500">Create your account</p>
11 </div>
app/views/sessions/new.html.erb
+6 -1
@@ -5,7 +5,7 @@
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">siGit Code &amp; Deploy</span>
8 + <span class="text-xl font-semibold tracking-tight">Code &amp; Deploy</span>
9 <% end %>
10 <p class="text-sm text-gray-500">Sign in to continue</p>
11 </div>
@@ -51,6 +51,11 @@
51 No account?
52 <%= link_to "Create one", signup_path, class: "text-gray-400 hover:text-gray-200 underline underline-offset-2" %>
53 </p>
54 +
55 + <p class="mt-3 text-center text-xs text-gray-500">
56 + Didn't get a confirmation email?
57 + <%= link_to "Resend it", resend_confirmation_path, class: "text-gray-400 hover:text-gray-200 underline underline-offset-2" %>
58 + </p>
59 </div>
60
61 </div>
app/views/shared/_navbar.html.erb
+1 -1
@@ -4,7 +4,7 @@
4 <div class="flex items-center gap-6">
5 <%= link_to root_path, class: "flex items-center gap-2 text-gray-100 hover:text-brand-500 transition-colors" do %>
6 <img src="/icon.png" alt="siGit" class="h-6 w-auto">
7 - <span class="font-semibold text-base tracking-tight">siGit Code &amp; Deploy</span>
7 + <span class="font-semibold text-base tracking-tight">Code &amp; Deploy</span>
8 <% end %>
9
10 <% if signed_in? %>
config/routes.rb
+4
@@ -18,6 +18,10 @@ Rails.application.routes.draw do
18 get "/auth/signup", to: "registrations#new", as: :signup
19 post "/auth/signup", to: "registrations#create", as: :signup_create
20
21 + # Resend confirmation email
22 + get "/auth/confirmation/resend", to: "confirmations#new", as: :resend_confirmation
23 + post "/auth/confirmation/resend", to: "confirmations#create"
24 +
25 # Repository creation
26 get "/new", to: "repositories#new", as: :new_repository
27 post "/new", to: "repositories#create"