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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ A social app to help people stay accountable by joining challenges (e.g., gym ro
- ✅ Post updates (text/photo).
- Progress input fields customized to challenge type (reps, minutes, etc.).
- ✅ Comment and react.
- ~~Invite friends via email.~~ (may be later)
- ~~Invite friends via email.~~ (may be later). ✅ Just a shareable link to challenge.
- ✅ Manual reward/bet tracking (no payments yet).
- **Progress Tracking**:
- ✅ Manual reward/bet tracking (no payments yet).
Expand Down
13 changes: 7 additions & 6 deletions app/controllers/challenge_stories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def index

def show
if likely_webview?
@likely_webview_shared_url = params[:url] || root_url
@likely_webview_shared_url = params[:url] || request.original_url || root_url
end

@challenge_story = ChallengeStory
Expand All @@ -33,19 +33,20 @@ def show
)
.find(params[:id])

@participants = @challenge_story.challenge_participants
.select(&:active?)
.sort_by(&:created_at)

if current_user
@current_challenge_participant = @challenge_story.challenge_participants.find { |p| p.user_id == current_user.id }
@current_challenge_participant = @participants.find { |p| p.user_id == current_user.id }

time_limit_till_next_check_in = 12.hours
recent_check_in = @current_challenge_participant&.challenge_check_ins&.where(created_at: time_limit_till_next_check_in.ago..Time.zone.now)&.order(:created_at)&.last
@time_of_a_next_check_in = if recent_check_in.present?
recent_check_in.created_at + time_limit_till_next_check_in
end
end

@participants = @challenge_story.challenge_participants
.select(&:active?)
.sort_by(&:created_at)
end
end

def new
Expand Down
11 changes: 10 additions & 1 deletion app/controllers/credentials_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

class CredentialsController < ApplicationController
before_action :require_current_user!
before_action :check_webview, only: [:index]

def index
end
Expand Down Expand Up @@ -30,7 +31,7 @@ def callback
webauthn_credential.verify(session[:current_registration]["challenge"], user_verification: true)

credential = current_user.credentials.find_or_initialize_by(
external_id: Base64.strict_encode64(webauthn_credential.raw_id)
external_id: Base64.urlsafe_encode64(webauthn_credential.raw_id, padding: false)
)

if credential.update(
Expand All @@ -56,4 +57,12 @@ def destroy

redirect_to credentials_path
end

private

def check_webview
if likely_webview?
redirect_to unsupported_browser_path(url: request.original_url)
end
end
end
11 changes: 11 additions & 0 deletions app/controllers/unsupported_browser_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class UnsupportedBrowserController < ApplicationController
def index
@unsupported_url = params[:url] || request.referer || root_url

if likely_webview?
@webview_again = true
elsif params[:url].present?
redirect_to @unsupported_url
end
end
end
20 changes: 13 additions & 7 deletions app/controllers/webauthn/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Webauthn
class RegistrationsController < ApplicationController
before_action :check_webview, only: [:new]

def new
end

Expand All @@ -19,13 +21,9 @@ def create
if user.valid?
session[:current_registration] = {challenge: create_options.challenge, user_attributes: user.attributes}

respond_to do |format|
format.json { render json: create_options }
end
render json: create_options
else
respond_to do |format|
format.json { render json: {errors: user.errors.full_messages}, status: :unprocessable_content }
end
render json: {errors: user.errors.full_messages}, status: :unprocessable_content
end
end

Expand All @@ -38,7 +36,7 @@ def callback
webauthn_credential.verify(session[:current_registration]["challenge"], user_verification: true)

user.credentials.build(
external_id: Base64.strict_encode64(webauthn_credential.raw_id),
external_id: Base64.urlsafe_encode64(webauthn_credential.raw_id, padding: false),
nickname: params[:credential_nickname],
public_key: webauthn_credential.public_key,
sign_count: webauthn_credential.sign_count
Expand All @@ -57,5 +55,13 @@ def callback
session.delete(:current_registration)
end
end

private

def check_webview
if likely_webview?
redirect_to unsupported_browser_path(url: request.original_url)
end
end
end
end
18 changes: 11 additions & 7 deletions app/controllers/webauthn/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Webauthn
class SessionsController < ApplicationController
before_action :check_webview, only: [:new]

def new
end

Expand All @@ -16,13 +18,9 @@ def create

session[:current_authentication] = {challenge: get_options.challenge, username: session_params[:username]}

respond_to do |format|
format.json { render json: get_options }
end
render json: get_options
else
respond_to do |format|
format.json { render json: {errors: ["Username doesn't exist"]}, status: :unprocessable_content }
end
render json: {errors: ["Username doesn't exist"]}, status: :unprocessable_content
end
end

Expand All @@ -32,7 +30,7 @@ def callback
user = User.find_by(username: session[:current_authentication]["username"])
raise "user #{session[:current_authentication]["username"]} never initiated sign up" unless user

credential = user.credentials.find_by(external_id: Base64.strict_encode64(webauthn_credential.raw_id))
credential = user.credentials.find_by(external_id: Base64.urlsafe_encode64(webauthn_credential.raw_id, padding: false))

begin
webauthn_credential.verify(
Expand Down Expand Up @@ -73,5 +71,11 @@ def test_sign_in
def session_params
params.require(:session).permit(:username)
end

def check_webview
if likely_webview?
redirect_to unsupported_browser_path(url: request.original_url)
end
end
end
end
67 changes: 50 additions & 17 deletions app/javascript/controllers/new_registration_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,68 @@ import * as Credential from "credential";
import { MDCTextField } from '@material/textfield';

export default class extends Controller {
static targets = ["usernameField"]
static targets = ["usernameField", "submitButton"]

create(event) {
var [data, status, xhr] = event.detail;
console.log(data);
var credentialOptions = data;

// Registration
if (credentialOptions["user"]) {
var credential_nickname = event.target.querySelector("input[name='registration[nickname]']").value;
var callback_url = `/webauthn/registration/callback?credential_nickname=${credential_nickname}`
submit(event) {
event.preventDefault();

const form = event.target;
const formData = new FormData(form);
const submitButton = form.querySelector('[type="submit"]');

// Disable button during submission
if (submitButton) submitButton.disabled = true;

fetch(form.action, {
method: "POST",
body: formData,
headers: {
"Accept": "application/json"
},
credentials: "same-origin"
})
.then(response => {
if (response.ok) {
return response.json().then(data => this.handleSuccess(data, form));
} else {
return response.json().then(data => this.handleError(data, submitButton));
}
})
.catch(error => {
console.error("Form submission error:", error);
if (submitButton) submitButton.disabled = false;
});
}

Credential.create(encodeURI(callback_url), credentialOptions);
handleSuccess(data, form) {
console.log("Registration options:", data);

// Registration - check for user object in response
if (data.user) {
const credential_nickname = form.querySelector("input[name='registration[nickname]']").value;
const callback_url = `/webauthn/registration/callback?credential_nickname=${encodeURIComponent(credential_nickname)}`;

Credential.create(callback_url, data);
}
}

error(event) {
let response = event.detail[0];
handleError(response, submitButton) {
console.log("Registration error:", response);

// Re-enable submit button
if (submitButton) submitButton.disabled = false;

// Display error in helper text
let helperText = this.element.querySelector('.mdc-text-field-helper-text');
if (helperText && response["errors"] && response["errors"].length > 0) {
helperText.textContent = response["errors"][0];
if (helperText && response.errors && response.errors.length > 0) {
helperText.textContent = response.errors[0];
helperText.classList.add('mdc-text-field-helper-text--persistent');
}

// Mark field as invalid
let usernameField = new MDCTextField(this.usernameFieldTarget);
usernameField.valid = false;
if (this.hasUsernameFieldTarget) {
let usernameField = new MDCTextField(this.usernameFieldTarget);
usernameField.valid = false;
}
}
}
57 changes: 47 additions & 10 deletions app/javascript/controllers/new_session_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,54 @@ import { MDCTextField } from '@material/textfield';
export default class extends Controller {
static targets = ["usernameField"]

create(event) {
var [data, status, xhr] = event.detail;
console.log(data);
var credentialOptions = data;
Credential.get(credentialOptions);
submit(event) {
event.preventDefault();

const form = event.target;
const formData = new FormData(form);
const submitButton = form.querySelector('[type="submit"]');

// Disable button during submission
if (submitButton) submitButton.disabled = true;

fetch(form.action, {
method: "POST",
body: formData,
headers: {
"Accept": "application/json"
},
credentials: "same-origin"
})
.then(response => {
if (response.ok) {
return response.json().then(data => this.handleSuccess(data));
} else {
return response.json().then(data => this.handleError(data, submitButton));
}
})
.catch(error => {
console.error("Form submission error:", error);
if (submitButton) submitButton.disabled = false;
});
}

error(event) {
let response = event.detail[0];
let usernameField = new MDCTextField(this.usernameFieldTarget);
usernameField.valid = false;
usernameField.helperTextContent = response["errors"][0];
handleSuccess(data) {
console.log("Session options:", data);
Credential.get(data);
}

handleError(response, submitButton) {
console.log("Session error:", response);

// Re-enable submit button
if (submitButton) submitButton.disabled = false;

if (this.hasUsernameFieldTarget) {
let usernameField = new MDCTextField(this.usernameFieldTarget);
usernameField.valid = false;
if (response.errors && response.errors[0]) {
usernameField.helperTextContent = response.errors[0];
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { Controller } from "@hotwired/stimulus";
import { supported as WebAuthnSupported } from "@github/webauthn-json";
import { supported } from "credential";

export default class extends Controller {
static targets = ["message"]

connect() {
if (!WebAuthnSupported()) {
if (!supported()) {
this.messageTarget.innerHTML = "This browser doesn't support WebAuthn API";
this.element.classList.remove("hidden");
this.showUnsupportedView();
} else {
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then((available) => {
if (!available) {
this.messageTarget.innerHTML = "We couldn't detect a user-verifying platform authenticator";
this.element.classList.remove("hidden");
this.showUnsupportedView();
}
});
}
}

showUnsupportedView() {
this.element.classList.remove("hidden");
}
}

Loading