Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ def add_email
return
end

if EmailVerificationRequest.exists?(email: email)
if EmailVerificationRequest.where(deleted_at: nil).exists?(email: email)
redirect_to my_settings_path, alert: "This email is already pending verification"
return
end
Expand All @@ -211,6 +211,40 @@ def add_email
redirect_to my_settings_path, alert: "Failed to add email: #{e.record.errors.full_messages.join(', ')}"
end

def resend_email_verification
unless current_user
redirect_to root_path, alert: "Please sign in first"
return
end

email = params[:email].to_s.downcase
verification_request = current_user.email_verification_requests.valid.find_by(email: email)

unless verification_request
redirect_to my_settings_path, alert: "No pending verification found for that email"
return
end

unless verification_request.resend_available?
cooldown_minutes = (verification_request.resend_cooldown_seconds / 60.0).ceil
redirect_to my_settings_path,
alert: "Please wait #{cooldown_minutes} minute#{'s' unless cooldown_minutes == 1} before resending"
return
end

verification_request.refresh_for_resend!

if Rails.env.production?
EmailVerificationMailer.verify_email(verification_request).deliver_later
else
EmailVerificationMailer.verify_email(verification_request).deliver_now
end

redirect_to my_settings_path, notice: "Verification email resent!"
rescue ActiveRecord::RecordInvalid => e
redirect_to my_settings_path, alert: "Failed to resend verification email: #{e.record.errors.full_messages.join(', ')}"
end

def unlink_email
unless current_user
redirect_to root_path, alert: "Please sign in first to unlink an email"
Expand All @@ -224,7 +258,17 @@ def unlink_email
)

unless email_record
redirect_to my_settings_path, alert: "Email must exist to be unlinked"
pending_request = current_user.email_verification_requests
.where(deleted_at: nil)
.find_by(email: email)

unless pending_request
redirect_to my_settings_path, alert: "Email must exist to be removed"
return
end

pending_request.soft_delete!
redirect_to my_settings_path, notice: "Pending email removed!"
return
end

Expand All @@ -238,7 +282,7 @@ def unlink_email
)

email_record.destroy!
email_verification_request&.destroy
email_verification_request&.soft_delete!

redirect_to my_settings_path, notice: "Email unlinked!"
rescue ActiveRecord::RecordNotDestroyed => e
Expand Down
1 change: 1 addition & 0 deletions app/controllers/settings/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def paths_props
github_auth_path: github_auth_path,
github_unlink_path: github_unlink_path,
add_email_path: add_email_auth_path,
resend_email_verification_path: resend_email_verification_auth_path,
unlink_email_path: unlink_email_auth_path,
rotate_api_key_path: my_settings_rotate_api_key_path,
export_all_heartbeats_path: export_my_heartbeats_path(all_data: "true"),
Expand Down
33 changes: 26 additions & 7 deletions app/controllers/settings/integrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,31 @@ def section_props
enabled: true,
).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS)
channel_ids = enabled_sailors_logs.pluck(:slack_channel_id)
pending_email_requests = @user.email_verification_requests
.where(deleted_at: nil)
.order(created_at: :desc)
verified_emails = @user.email_addresses.map { |email|
{
email: email.email,
source: email.source&.humanize || "Unknown",
can_unlink: @user.can_delete_email_address?(email),
pending: false,
can_resend: false,
resend_cooldown_seconds: 0
}
}

pending_emails = pending_email_requests.map { |request|
{
email: request.email,
source: "Pending verification",
can_unlink: true,
pending: true,
expired: request.expired?,
can_resend: !request.expired? && request.resend_available?,
resend_cooldown_seconds: request.resend_cooldown_seconds
}
}

{
settings_update_path: my_settings_integrations_path,
Expand All @@ -50,13 +75,7 @@ def section_props
username: @user.github_username,
profile_url: (@user.github_username.present? ? "https://github.com/#{@user.github_username}" : nil)
},
emails: @user.email_addresses.map { |email|
{
email: email.email,
source: email.source&.humanize || "Unknown",
can_unlink: @user.can_delete_email_address?(email)
}
},
emails: (verified_emails + pending_emails),
paths: paths_props
}
end
Expand Down
49 changes: 47 additions & 2 deletions app/javascript/pages/Users/Settings/Integrations.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
});

function formatCooldown(seconds: number): string {
if (seconds <= 0) return "";

const minutes = Math.ceil(seconds / 60);
return `Resend in ${minutes}m`;
}
</script>

<svelte:head>
Expand Down Expand Up @@ -181,9 +188,47 @@
class="flex flex-wrap items-center gap-2 rounded-md border border-surface-200 bg-darker px-3 py-2"
>
<div class="grow text-sm text-surface-content">
<p>{email.email}</p>
<p class="flex items-center gap-2">
<span>{email.email}</span>
{#if email.pending}
<span
class="rounded-md border border-surface-200 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted"
>
Unverified
</span>
{#if email.expired}
<span
class="rounded-md border border-surface-200 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted"
>
Expired
</span>
{/if}
{/if}
</p>
<p class="text-xs text-muted">{email.source}</p>
</div>
{#if email.pending}
<form method="post" action={paths.resend_email_verification_path}>
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<input type="hidden" name="email" value={email.email} />
<Button
type="submit"
variant="surface"
size="xs"
class="rounded-md"
disabled={!email.can_resend}
>
{email.can_resend
? "Resend"
: formatCooldown(email.resend_cooldown_seconds) ||
"Resend soon"}
</Button>
</form>
{/if}
Comment thread
matmanna marked this conversation as resolved.
{#if email.can_unlink}
<form method="post" action={paths.unlink_email_path}>
<input type="hidden" name="_method" value="delete" />
Expand All @@ -199,7 +244,7 @@
size="xs"
class="rounded-md"
>
Unlink
Remove
</Button>
</form>
{/if}
Expand Down
5 changes: 5 additions & 0 deletions app/javascript/pages/Users/Settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export type PathsProps = {
github_auth_path: string;
github_unlink_path: string;
add_email_path: string;
resend_email_verification_path: string;
unlink_email_path: string;
rotate_api_key_path: string;
export_all_heartbeats_path: string;
Expand Down Expand Up @@ -135,6 +136,10 @@ export type EmailProps = {
email: string;
source: string;
can_unlink: boolean;
pending: boolean;
expired?: boolean;
can_resend: boolean;
resend_cooldown_seconds: number;
};

export type BadgesProps = {
Expand Down
24 changes: 23 additions & 1 deletion app/models/email_verification_request.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class EmailVerificationRequest < ApplicationRecord
RESEND_COOLDOWN = 10.minutes

belongs_to :user

validates :email, presence: true,
Expand All @@ -12,7 +14,7 @@ class EmailVerificationRequest < ApplicationRecord
before_validation :downcase_email

scope :valid, -> { where("expires_at > ? AND deleted_at IS NULL", Time.current) }
scope :expired, -> { where("expires_at <= ?", Time.current) }
scope :expired, -> { where("expires_at <= ? AND deleted_at IS NULL", Time.current) }

def expired?
expires_at <= Time.current
Expand All @@ -22,6 +24,26 @@ def soft_delete!
update!(deleted_at: Time.current)
end

def resend_available_at
([ created_at, updated_at ].compact.max || Time.current) + RESEND_COOLDOWN
end

def resend_available?
resend_available_at <= Time.current
end

def resend_cooldown_seconds
seconds = (resend_available_at - Time.current).ceil
[ seconds, 0 ].max
end

def refresh_for_resend!
update!(
token: SecureRandom.urlsafe_base64(32),
expires_at: 30.minutes.from_now
)
end

def verify!
email_address = user.email_addresses.create!(
email: email,
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def matches?(request)
delete "/auth/github/unlink", to: "sessions#github_unlink", as: :github_unlink
post "/auth/email", to: "sessions#email", as: :email_auth
post "/auth/email/add", to: "sessions#add_email", as: :add_email_auth
post "/auth/email/resend_verification", to: "sessions#resend_email_verification", as: :resend_email_verification_auth
delete "/auth/email/unlink", to: "sessions#unlink_email", as: :unlink_email_auth
get "/auth/token/:token", to: "sessions#token", as: :auth_token
get "/auth/close_window", to: "sessions#close_window", as: :close_window
Expand Down
52 changes: 52 additions & 0 deletions test/controllers/sessions_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,33 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_equal "new-address@example.com", user.reload.email_verification_requests.last.email
end

test "resend_email_verification enforces cooldown" do
user = User.create!
verification_request = user.email_verification_requests.create!(email: "pending@example.com")
sign_in_as(user)

old_token = verification_request.token
post resend_email_verification_auth_path, params: { email: verification_request.email }

assert_response :redirect
assert_redirected_to my_settings_path
assert_equal old_token, verification_request.reload.token
end

test "resend_email_verification refreshes token after cooldown" do
user = User.create!
verification_request = user.email_verification_requests.create!(email: "pending-ok@example.com")
verification_request.update_columns(created_at: 11.minutes.ago, updated_at: 11.minutes.ago)
sign_in_as(user)

old_token = verification_request.token
post resend_email_verification_auth_path, params: { email: verification_request.email }

assert_response :redirect
assert_redirected_to my_settings_path
assert_not_equal old_token, verification_request.reload.token
end

test "unlink_email removes secondary signing-in email" do
user = User.create!
removable = user.email_addresses.create!(email: "remove-me@example.com", source: :signing_in)
Expand All @@ -265,6 +292,31 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_not user.reload.email_addresses.exists?(email: removable.email)
end

test "unlink_email removes pending verification request when email is unverified" do
user = User.create!
verification_request = user.email_verification_requests.create!(email: "pending-remove@example.com")
sign_in_as(user)

delete unlink_email_auth_path, params: { email: verification_request.email }

assert_response :redirect
assert_redirected_to my_settings_path
assert verification_request.reload.deleted_at.present?
end

test "unlink_email removes expired pending verification request" do
user = User.create!
verification_request = user.email_verification_requests.create!(email: "expired-remove@example.com")
verification_request.update_columns(expires_at: 1.minute.ago)
sign_in_as(user)

delete unlink_email_auth_path, params: { email: verification_request.email }

assert_response :redirect
assert_redirected_to my_settings_path
assert verification_request.reload.deleted_at.present?
end

test "auth token verifies email verification request token" do
user = User.create!
verification_request = user.email_verification_requests.create!(email: "verify-me@example.com")
Expand Down
Loading