Add email auth

This commit is contained in:
Max Wofford 2025-03-07 18:12:48 -05:00
parent a55248e842
commit 4271688194
13 changed files with 234 additions and 8 deletions

View file

@ -48,3 +48,67 @@
.project-durations {
margin-top: 1rem;
}
.auth-options {
max-width: 400px;
margin: 2rem auto;
text-align: center;
}
.auth-button {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 4px;
text-decoration: none;
font-weight: 500;
cursor: pointer;
border: none;
width: 100%;
margin: 0.5rem 0;
}
.auth-button.slack {
background-color: #4A154B;
color: white;
}
.auth-button.email {
background-color: #0070f3;
color: white;
}
.auth-divider {
margin: 1rem 0;
color: #666;
position: relative;
}
.auth-divider::before,
.auth-divider::after {
content: "";
position: absolute;
top: 50%;
width: 45%;
height: 1px;
background-color: #ddd;
}
.auth-divider::before {
left: 0;
}
.auth-divider::after {
right: 0;
}
.email-auth-form .field {
margin-bottom: 1rem;
}
.email-auth-form input[type="email"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}

View file

@ -27,6 +27,32 @@ class SessionsController < ApplicationController
end
end
def email
email_address = EmailAddress.find_by(email: params[:email].downcase)
if email_address
token = email_address.user.create_email_signin_token
AuthMailer.sign_in_email(email_address, token).deliver_later
redirect_to root_path, notice: "Check your email for a sign-in link!"
else
redirect_to root_path, alert: "Email not found. Please sign in with Slack first."
end
end
def token
valid_token = SignInToken.where(token: params[:token], used_at: nil)
.where("expires_at > ?", Time.current)
.first
if valid_token
valid_token.mark_used!
session[:user_id] = valid_token.user_id
redirect_to root_path, notice: "Successfully signed in!"
else
redirect_to root_path, alert: "Invalid or expired sign-in link"
end
end
def impersonate
unless current_user.admin?
redirect_to root_path, alert: "You are not authorized to impersonate users"

View file

@ -0,0 +1,11 @@
class AuthMailer < ApplicationMailer
def sign_in_email(email_address, token)
@token = token
@user = email_address.user
mail(
to: email_address.email,
subject: "Your Harbor sign-in link"
)
end
end

View file

@ -0,0 +1,15 @@
class EmailAddress < ApplicationRecord
belongs_to :user
validates :email, presence: true,
uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
before_validation :downcase_email
private
def downcase_email
self.email = email.downcase
end
end

View file

@ -0,0 +1,31 @@
class SignInToken < ApplicationRecord
belongs_to :user
enum :auth_type, {
email: 0,
slack: 1
}
validates :token, presence: true, uniqueness: true
validates :auth_type, presence: true
validates :expires_at, presence: true
before_validation :generate_token, on: :create
before_validation :set_expiration, on: :create
scope :valid, -> { where("expires_at > ? AND used_at IS NULL", Time.current) }
def mark_used!
update!(used_at: Time.current)
end
private
def generate_token
self.token ||= SecureRandom.urlsafe_base64(32)
end
def set_expiration
self.expires_at ||= 30.minutes.from_now
end
end

View file

@ -2,11 +2,12 @@ class User < ApplicationRecord
has_paper_trail
encrypts :slack_access_token
validates :email, presence: true, uniqueness: true
validates :slack_uid, presence: true, uniqueness: true
validates :username, presence: true
has_many :heartbeats
has_many :email_addresses
has_many :sign_in_tokens
has_many :hackatime_heartbeats,
foreign_key: :user_id,
@ -171,4 +172,12 @@ class User < ApplicationRecord
heartbeats.where(project: active_project).duration_seconds
end
def create_email_signin_token
sign_in_tokens.create!(auth_type: :email)
end
def find_valid_token(token)
sign_in_tokens.valid.find_by(token: token)
end
end

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
</head>
<body>
<h1>Welcome back to Harbor!</h1>
<p>
Click the link below to sign in to your account:
</p>
<p>
<%= link_to 'Sign in to Harbor', auth_token_url(@token.token) %>
</p>
<p>
This link will expire in 30 minutes and can only be used once.
</p>
<p>
If you didn't request this email, you can safely ignore it.
</p>
</body>
</html>

View file

@ -38,6 +38,13 @@
<% end %>
</li>
<% end %>
<% if Rails.env.development? %>
<li>
<%= link_to letter_opener_path, class: "nav-item #{current_page?(letter_opener_path) ? 'active' : ''}" do %>
Letter Opener
<% end %>
</li>
<% end %>
<% admin_tool(nil, "li") do %>
<%= link_to avo_path, class: "nav-item #{current_page?(avo_path) ? 'active' : ''}" do %>
Avo

View file

@ -19,5 +19,18 @@
Loading project durations...
</div>
<% end %>
<% else %>
<div class="auth-options">
<%= link_to "Sign in with Slack", slack_auth_path, class: "auth-button slack" %>
<div class="auth-divider">or</div>
<%= form_tag email_auth_path, class: "email-auth-form" do %>
<div class="field">
<%= email_field_tag :email, nil, placeholder: "Enter your email", required: true %>
</div>
<%= submit_tag "Send sign-in link", class: "auth-button email" %>
<% end %>
</div>
<% end %>
</div>

View file

@ -12,10 +12,12 @@
<%= form_with model: @user,
url: @is_own_settings ? my_settings_path : settings_user_path(@user),
method: :patch do |f| %>
<div>
<%= f.label "Update my Slack status with my current project" %>
<%= f.check_box :uses_slack_status %>
</div>
<fieldset>
<label for="switch-1">
<input type="checkbox" id="switch-1" name="switch-1" role="switch" checked="">
<%= f.label "Update my Slack status with my current project" %>
</label>
</fieldset>
<%= f.submit "Save Settings" %>
<% end %>
</div>
@ -47,9 +49,12 @@
<h2>Config file</h2>
<%= render "wakatime_config_display" %>
<p style="font-size: 0.8rem; color: var(--muted-color);">
This file is located in <code>~/.wakatime.cfg</code> on your computer.
You can configure it with <a href="https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#ini-config-file">other settings</a> as well.
<p>
<br>
<small>
This file is located in <code>~/.wakatime.cfg</code> on your computer.
You can configure it with <a href="https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#ini-config-file">other settings</a> as well.
</small>
</p>
</div>

View file

@ -40,6 +40,10 @@ Rails.application.configure do
# Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
# Preview emails in the browser [https://github.com/ryanb/letter_opener]
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_deliveries = true
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log

View file

@ -44,8 +44,11 @@ Rails.application.routes.draw do
end
end
# Auth routes
get "/auth/slack", to: "sessions#new", as: :slack_auth
get "/auth/slack/callback", to: "sessions#create"
post "/auth/email", to: "sessions#email", as: :email_auth
get "/auth/token/:token", to: "sessions#token", as: :auth_token
delete "signout", to: "sessions#destroy", as: "signout"
resources :leaderboards, only: [ :index ]

17
todo.md Normal file
View file

@ -0,0 +1,17 @@
import old data
- [ ] make heartbeats work without users
- [ ] user creation without slack signin
get wakatime working
- [x] vs code extension not working
- [ ] wtf is the summary endpoint doing?
- [x] use proxy to mitm the bulk upload endpoint
summary api
- [ ] give api keys for each user w/ different types of access (?)
- [ ] give summary endpoint with generated summaries
email auth
- [x] create emails on user
- [x] migrate emails to email addresses model
- [ ]