aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/actions/api/me/show.cr5
-rw-r--r--src/actions/api/sign_ins/create.cr13
-rw-r--r--src/actions/api/sign_ups/create.cr8
-rw-r--r--src/actions/api_action.cr17
-rw-r--r--src/actions/browser_action.cr45
-rw-r--r--src/actions/errors/show.cr63
-rw-r--r--src/actions/home/index.cr18
-rw-r--r--src/actions/me/show.cr5
-rw-r--r--src/actions/mixins/.keep0
-rw-r--r--src/actions/mixins/api/auth/helpers.cr28
-rw-r--r--src/actions/mixins/api/auth/require_auth_token.cr34
-rw-r--r--src/actions/mixins/api/auth/skip_require_auth_token.cr10
-rw-r--r--src/actions/mixins/auth/allow_guests.cr10
-rw-r--r--src/actions/mixins/auth/password_resets/base.cr7
-rw-r--r--src/actions/mixins/auth/password_resets/find_user.cr5
-rw-r--r--src/actions/mixins/auth/password_resets/require_token.cr17
-rw-r--r--src/actions/mixins/auth/password_resets/token_from_session.cr5
-rw-r--r--src/actions/mixins/auth/redirect_signed_in_users.cr19
-rw-r--r--src/actions/mixins/auth/require_sign_in.cr21
-rw-r--r--src/actions/mixins/auth/test_backdoor.cr13
-rw-r--r--src/actions/password_reset_requests/create.cr15
-rw-r--r--src/actions/password_reset_requests/new.cr7
-rw-r--r--src/actions/password_resets/create.cr17
-rw-r--r--src/actions/password_resets/edit.cr8
-rw-r--r--src/actions/password_resets/new.cr20
-rw-r--r--src/actions/sign_ins/create.cr16
-rw-r--r--src/actions/sign_ins/delete.cr7
-rw-r--r--src/actions/sign_ins/new.cr7
-rw-r--r--src/actions/sign_ups/create.cr16
-rw-r--r--src/actions/sign_ups/new.cr7
-rw-r--r--src/app.cr26
-rw-r--r--src/app_database.cr2
-rw-r--r--src/app_server.cr26
-rw-r--r--src/components/.keep0
-rw-r--r--src/components/base_component.cr2
-rw-r--r--src/components/shared/field.cr57
-rw-r--r--src/components/shared/field_errors.cr16
-rw-r--r--src/components/shared/flash_messages.cr11
-rw-r--r--src/components/shared/layout_head.cr18
-rw-r--r--src/css/app.scss66
-rw-r--r--src/diskfloppydotme.cr6
-rw-r--r--src/emails/.keep0
-rw-r--r--src/emails/base_email.cr15
-rw-r--r--src/emails/password_reset_request_email.cr13
-rw-r--r--src/emails/templates/password_reset_request_email/html.ecr3
-rw-r--r--src/emails/templates/password_reset_request_email/text.ecr3
-rw-r--r--src/js/app.js7
-rw-r--r--src/models/base_model.cr5
-rw-r--r--src/models/mixins/.keep0
-rw-r--r--src/models/user.cr13
-rw-r--r--src/models/user_token.cr30
-rw-r--r--src/operations/.keep0
-rw-r--r--src/operations/mixins/.keep0
-rw-r--r--src/operations/mixins/password_validations.cr12
-rw-r--r--src/operations/mixins/user_from_email.cr7
-rw-r--r--src/operations/request_password_reset.cr25
-rw-r--r--src/operations/reset_password.cr11
-rw-r--r--src/operations/sign_in_user.cr40
-rw-r--r--src/operations/sign_up_user.cr14
-rw-r--r--src/pages/auth_layout.cr27
-rw-r--r--src/pages/errors/show_page.cr93
-rw-r--r--src/pages/main_layout.cr45
-rw-r--r--src/pages/me/show_page.cr21
-rw-r--r--src/pages/password_reset_requests/new_page.cr15
-rw-r--r--src/pages/password_resets/new_page.cr18
-rw-r--r--src/pages/sign_ins/new_page.cr23
-rw-r--r--src/pages/sign_ups/new_page.cr22
-rw-r--r--src/queries/.keep0
-rw-r--r--src/queries/mixins/.keep0
-rw-r--r--src/queries/user_query.cr2
-rw-r--r--src/serializers/.keep0
-rw-r--r--src/serializers/base_serializer.cr7
-rw-r--r--src/serializers/error_serializer.cr14
-rw-r--r--src/serializers/user_serializer.cr8
-rw-r--r--src/shards.cr10
-rw-r--r--src/start_server.cr16
76 files changed, 1212 insertions, 0 deletions
diff --git a/src/actions/api/me/show.cr b/src/actions/api/me/show.cr
new file mode 100644
index 0000000..0060271
--- /dev/null
+++ b/src/actions/api/me/show.cr
@@ -0,0 +1,5 @@
+class Api::Me::Show < ApiAction
+ get "/api/me" do
+ json UserSerializer.new(current_user)
+ end
+end
diff --git a/src/actions/api/sign_ins/create.cr b/src/actions/api/sign_ins/create.cr
new file mode 100644
index 0000000..3670356
--- /dev/null
+++ b/src/actions/api/sign_ins/create.cr
@@ -0,0 +1,13 @@
+class Api::SignIns::Create < ApiAction
+ include Api::Auth::SkipRequireAuthToken
+
+ post "/api/sign_ins" do
+ SignInUser.run(params) do |operation, user|
+ if user
+ json({token: UserToken.generate(user)})
+ else
+ raise Avram::InvalidOperationError.new(operation)
+ end
+ end
+ end
+end
diff --git a/src/actions/api/sign_ups/create.cr b/src/actions/api/sign_ups/create.cr
new file mode 100644
index 0000000..15bbd04
--- /dev/null
+++ b/src/actions/api/sign_ups/create.cr
@@ -0,0 +1,8 @@
+class Api::SignUps::Create < ApiAction
+ include Api::Auth::SkipRequireAuthToken
+
+ post "/api/sign_ups" do
+ user = SignUpUser.create!(params)
+ json({token: UserToken.generate(user)})
+ end
+end
diff --git a/src/actions/api_action.cr b/src/actions/api_action.cr
new file mode 100644
index 0000000..a16fd09
--- /dev/null
+++ b/src/actions/api_action.cr
@@ -0,0 +1,17 @@
+# Include modules and add methods that are for all API requests
+abstract class ApiAction < Lucky::Action
+ # APIs typically do not need to send cookie/session data.
+ # Remove this line if you want to send cookies in the response header.
+ disable_cookies
+ accepted_formats [:json]
+
+ include Api::Auth::Helpers
+
+ # By default all actions require sign in.
+ # Add 'include Api::Auth::SkipRequireAuthToken' to your actions to allow all requests.
+ include Api::Auth::RequireAuthToken
+
+ # By default all actions are required to use underscores to separate words.
+ # Add 'include Lucky::SkipRouteStyleCheck' to your actions if you wish to ignore this check for specific routes.
+ include Lucky::EnforceUnderscoredRoute
+end
diff --git a/src/actions/browser_action.cr b/src/actions/browser_action.cr
new file mode 100644
index 0000000..674f208
--- /dev/null
+++ b/src/actions/browser_action.cr
@@ -0,0 +1,45 @@
+abstract class BrowserAction < Lucky::Action
+ include Lucky::ProtectFromForgery
+
+ # By default all actions are required to use underscores.
+ # Add `include Lucky::SkipRouteStyleCheck` to your actions if you wish to ignore this check for specific routes.
+ include Lucky::EnforceUnderscoredRoute
+
+ # This module disables Google FLoC by setting the
+ # [Permissions-Policy](https://github.com/WICG/floc) HTTP header to `interest-cohort=()`.
+ #
+ # This header is a part of Google's Federated Learning of Cohorts (FLoC) which is used
+ # to track browsing history instead of using 3rd-party cookies.
+ #
+ # Remove this include if you want to use the FLoC tracking.
+ include Lucky::SecureHeaders::DisableFLoC
+
+ accepted_formats [:html, :json], default: :html
+
+ # This module provides current_user, sign_in, and sign_out methods
+ include Authentic::ActionHelpers(User)
+
+ # When testing you can skip normal sign in by using `visit` with the `as` param
+ #
+ # flow.visit Me::Show, as: UserFactory.create
+ include Auth::TestBackdoor
+
+ # By default all actions that inherit 'BrowserAction' require sign in.
+ #
+ # You can remove the 'include Auth::RequireSignIn' below to allow anyone to
+ # access actions that inherit from 'BrowserAction' or you can
+ # 'include Auth::AllowGuests' in individual actions to skip sign in.
+ include Auth::RequireSignIn
+
+ # `expose` means that `current_user` will be passed to pages automatically.
+ #
+ # In default Lucky apps, the `MainLayout` declares it `needs current_user : User`
+ # so that any page that inherits from MainLayout can use the `current_user`
+ expose current_user
+
+ # This method tells Authentic how to find the current user
+ # The 'memoize' macro makes sure only one query is issued to find the user
+ private memoize def find_current_user(id : String | User::PrimaryKeyType) : User?
+ UserQuery.new.id(id).first?
+ end
+end
diff --git a/src/actions/errors/show.cr b/src/actions/errors/show.cr
new file mode 100644
index 0000000..d01ed54
--- /dev/null
+++ b/src/actions/errors/show.cr
@@ -0,0 +1,63 @@
+# This class handles error responses and reporting.
+#
+# https://luckyframework.org/guides/http-and-routing/error-handling
+class Errors::Show < Lucky::ErrorAction
+ DEFAULT_MESSAGE = "Something went wrong."
+ default_format :html
+ dont_report [Lucky::RouteNotFoundError, Avram::RecordNotFoundError]
+
+ def render(error : Lucky::RouteNotFoundError | Avram::RecordNotFoundError)
+ if html?
+ error_html "Sorry, we couldn't find that page.", status: 404
+ else
+ error_json "Not found", status: 404
+ end
+ end
+
+ # When the request is JSON and an InvalidOperationError is raised, show a
+ # helpful error with the param that is invalid, and what was wrong with it.
+ def render(error : Avram::InvalidOperationError)
+ if html?
+ error_html DEFAULT_MESSAGE, status: 500
+ else
+ error_json \
+ message: error.renderable_message,
+ details: error.renderable_details,
+ param: error.invalid_attribute_name,
+ status: 400
+ end
+ end
+
+ # Always keep this below other 'render' methods or it may override your
+ # custom 'render' methods.
+ def render(error : Lucky::RenderableError)
+ if html?
+ error_html DEFAULT_MESSAGE, status: error.renderable_status
+ else
+ error_json error.renderable_message, status: error.renderable_status
+ end
+ end
+
+ # If none of the 'render' methods return a response for the raised Exception,
+ # Lucky will use this method.
+ def default_render(error : Exception) : Lucky::Response
+ if html?
+ error_html DEFAULT_MESSAGE, status: 500
+ else
+ error_json DEFAULT_MESSAGE, status: 500
+ end
+ end
+
+ private def error_html(message : String, status : Int)
+ context.response.status_code = status
+ html_with_status Errors::ShowPage, status, message: message, status_code: status
+ end
+
+ private def error_json(message : String, status : Int, details = nil, param = nil)
+ json ErrorSerializer.new(message: message, details: details, param: param), status: status
+ end
+
+ private def report(error : Exception) : Nil
+ # Send to Rollbar, send an email, etc.
+ end
+end
diff --git a/src/actions/home/index.cr b/src/actions/home/index.cr
new file mode 100644
index 0000000..f780130
--- /dev/null
+++ b/src/actions/home/index.cr
@@ -0,0 +1,18 @@
+class Home::Index < BrowserAction
+ include Auth::AllowGuests
+
+ get "/" do
+ if current_user?
+ redirect Me::Show
+ else
+ # When you're ready change this line to:
+ #
+ # redirect SignIns::New
+ #
+ # Or maybe show signed out users a marketing page:
+ #
+ # html Marketing::IndexPage
+ html Lucky::WelcomePage
+ end
+ end
+end
diff --git a/src/actions/me/show.cr b/src/actions/me/show.cr
new file mode 100644
index 0000000..5e35848
--- /dev/null
+++ b/src/actions/me/show.cr
@@ -0,0 +1,5 @@
+class Me::Show < BrowserAction
+ get "/me" do
+ html ShowPage
+ end
+end
diff --git a/src/actions/mixins/.keep b/src/actions/mixins/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/actions/mixins/.keep
diff --git a/src/actions/mixins/api/auth/helpers.cr b/src/actions/mixins/api/auth/helpers.cr
new file mode 100644
index 0000000..6b51cb5
--- /dev/null
+++ b/src/actions/mixins/api/auth/helpers.cr
@@ -0,0 +1,28 @@
+module Api::Auth::Helpers
+ # The 'memoize' macro makes sure only one query is issued to find the user
+ memoize def current_user? : User?
+ auth_token.try do |value|
+ user_from_auth_token(value)
+ end
+ end
+
+ private def auth_token : String?
+ bearer_token || token_param
+ end
+
+ private def bearer_token : String?
+ context.request.headers["Authorization"]?
+ .try(&.gsub("Bearer", ""))
+ .try(&.strip)
+ end
+
+ private def token_param : String?
+ params.get?(:auth_token)
+ end
+
+ private def user_from_auth_token(token : String) : User?
+ UserToken.decode_user_id(token).try do |user_id|
+ UserQuery.new.id(user_id).first?
+ end
+ end
+end
diff --git a/src/actions/mixins/api/auth/require_auth_token.cr b/src/actions/mixins/api/auth/require_auth_token.cr
new file mode 100644
index 0000000..e018638
--- /dev/null
+++ b/src/actions/mixins/api/auth/require_auth_token.cr
@@ -0,0 +1,34 @@
+module Api::Auth::RequireAuthToken
+ macro included
+ before require_auth_token
+ end
+
+ private def require_auth_token
+ if current_user?
+ continue
+ else
+ json auth_error_json, 401
+ end
+ end
+
+ private def auth_error_json
+ ErrorSerializer.new(
+ message: "Not authenticated.",
+ details: auth_error_details
+ )
+ end
+
+ private def auth_error_details : String
+ if auth_token
+ "The provided authentication token was incorrect."
+ else
+ "An authentication token is required. Please include a token in an 'auth_token' param or 'Authorization' header."
+ end
+ end
+
+ # Tells the compiler that the current_user is not nil since we have checked
+ # that the user is signed in
+ private def current_user : User
+ current_user?.as(User)
+ end
+end
diff --git a/src/actions/mixins/api/auth/skip_require_auth_token.cr b/src/actions/mixins/api/auth/skip_require_auth_token.cr
new file mode 100644
index 0000000..68098cf
--- /dev/null
+++ b/src/actions/mixins/api/auth/skip_require_auth_token.cr
@@ -0,0 +1,10 @@
+module Api::Auth::SkipRequireAuthToken
+ macro included
+ skip require_auth_token
+ end
+
+ # Since sign in is not required, current_user might be nil
+ def current_user : User?
+ current_user?
+ end
+end
diff --git a/src/actions/mixins/auth/allow_guests.cr b/src/actions/mixins/auth/allow_guests.cr
new file mode 100644
index 0000000..3961399
--- /dev/null
+++ b/src/actions/mixins/auth/allow_guests.cr
@@ -0,0 +1,10 @@
+module Auth::AllowGuests
+ macro included
+ skip require_sign_in
+ end
+
+ # Since sign in is not required, current_user might be nil
+ def current_user : User?
+ current_user?
+ end
+end
diff --git a/src/actions/mixins/auth/password_resets/base.cr b/src/actions/mixins/auth/password_resets/base.cr
new file mode 100644
index 0000000..77166a9
--- /dev/null
+++ b/src/actions/mixins/auth/password_resets/base.cr
@@ -0,0 +1,7 @@
+module Auth::PasswordResets::Base
+ macro included
+ include Auth::RedirectSignedInUsers
+ include Auth::PasswordResets::FindUser
+ include Auth::PasswordResets::RequireToken
+ end
+end
diff --git a/src/actions/mixins/auth/password_resets/find_user.cr b/src/actions/mixins/auth/password_resets/find_user.cr
new file mode 100644
index 0000000..cab02d5
--- /dev/null
+++ b/src/actions/mixins/auth/password_resets/find_user.cr
@@ -0,0 +1,5 @@
+module Auth::PasswordResets::FindUser
+ private def user : User
+ UserQuery.find(user_id)
+ end
+end
diff --git a/src/actions/mixins/auth/password_resets/require_token.cr b/src/actions/mixins/auth/password_resets/require_token.cr
new file mode 100644
index 0000000..15da423
--- /dev/null
+++ b/src/actions/mixins/auth/password_resets/require_token.cr
@@ -0,0 +1,17 @@
+module Auth::PasswordResets::RequireToken
+ macro included
+ before require_valid_password_reset_token
+ end
+
+ abstract def token : String
+ abstract def user : User
+
+ private def require_valid_password_reset_token
+ if Authentic.valid_password_reset_token?(user, token)
+ continue
+ else
+ flash.failure = "The password reset link is incorrect or expired. Please try again."
+ redirect to: PasswordResetRequests::New
+ end
+ end
+end
diff --git a/src/actions/mixins/auth/password_resets/token_from_session.cr b/src/actions/mixins/auth/password_resets/token_from_session.cr
new file mode 100644
index 0000000..820b91b
--- /dev/null
+++ b/src/actions/mixins/auth/password_resets/token_from_session.cr
@@ -0,0 +1,5 @@
+module Auth::PasswordResets::TokenFromSession
+ private def token : String
+ session.get?(:password_reset_token) || raise "Password reset token not found in session"
+ end
+end
diff --git a/src/actions/mixins/auth/redirect_signed_in_users.cr b/src/actions/mixins/auth/redirect_signed_in_users.cr
new file mode 100644
index 0000000..546bf7b
--- /dev/null
+++ b/src/actions/mixins/auth/redirect_signed_in_users.cr
@@ -0,0 +1,19 @@
+module Auth::RedirectSignedInUsers
+ macro included
+ include Auth::AllowGuests
+ before redirect_signed_in_users
+ end
+
+ private def redirect_signed_in_users
+ if current_user?
+ flash.success = "You are already signed in"
+ redirect to: Home::Index
+ else
+ continue
+ end
+ end
+
+ # current_user returns nil because signed in users are redirected.
+ def current_user
+ end
+end
diff --git a/src/actions/mixins/auth/require_sign_in.cr b/src/actions/mixins/auth/require_sign_in.cr
new file mode 100644
index 0000000..27a6f5e
--- /dev/null
+++ b/src/actions/mixins/auth/require_sign_in.cr
@@ -0,0 +1,21 @@
+module Auth::RequireSignIn
+ macro included
+ before require_sign_in
+ end
+
+ private def require_sign_in
+ if current_user?
+ continue
+ else
+ Authentic.remember_requested_path(self)
+ flash.info = "Please sign in first"
+ redirect to: SignIns::New
+ end
+ end
+
+ # Tells the compiler that the current_user is not nil since we have checked
+ # that the user is signed in
+ private def current_user : User
+ current_user?.as(User)
+ end
+end
diff --git a/src/actions/mixins/auth/test_backdoor.cr b/src/actions/mixins/auth/test_backdoor.cr
new file mode 100644
index 0000000..68c9d91
--- /dev/null
+++ b/src/actions/mixins/auth/test_backdoor.cr
@@ -0,0 +1,13 @@
+module Auth::TestBackdoor
+ macro included
+ before test_backdoor
+ end
+
+ private def test_backdoor
+ if LuckyEnv.test? && (user_id = params.get?(:backdoor_user_id))
+ user = UserQuery.find(user_id)
+ sign_in user
+ end
+ continue
+ end
+end
diff --git a/src/actions/password_reset_requests/create.cr b/src/actions/password_reset_requests/create.cr
new file mode 100644
index 0000000..8f3c513
--- /dev/null
+++ b/src/actions/password_reset_requests/create.cr
@@ -0,0 +1,15 @@
+class PasswordResetRequests::Create < BrowserAction
+ include Auth::RedirectSignedInUsers
+
+ post "/password_reset_requests" do
+ RequestPasswordReset.run(params) do |operation, user|
+ if user
+ PasswordResetRequestEmail.new(user).deliver
+ flash.success = "You should receive an email on how to reset your password shortly"
+ redirect SignIns::New
+ else
+ html NewPage, operation: operation
+ end
+ end
+ end
+end
diff --git a/src/actions/password_reset_requests/new.cr b/src/actions/password_reset_requests/new.cr
new file mode 100644
index 0000000..7d16a7d
--- /dev/null
+++ b/src/actions/password_reset_requests/new.cr
@@ -0,0 +1,7 @@
+class PasswordResetRequests::New < BrowserAction
+ include Auth::RedirectSignedInUsers
+
+ get "/password_reset_requests/new" do
+ html NewPage, operation: RequestPasswordReset.new
+ end
+end
diff --git a/src/actions/password_resets/create.cr b/src/actions/password_resets/create.cr
new file mode 100644
index 0000000..da1e711
--- /dev/null
+++ b/src/actions/password_resets/create.cr
@@ -0,0 +1,17 @@
+class PasswordResets::Create < BrowserAction
+ include Auth::PasswordResets::Base
+ include Auth::PasswordResets::TokenFromSession
+
+ post "/password_resets/:user_id" do
+ ResetPassword.update(user, params) do |operation, user|
+ if operation.saved?
+ session.delete(:password_reset_token)
+ sign_in user
+ flash.success = "Your password has been reset"
+ redirect to: Home::Index
+ else
+ html NewPage, operation: operation, user_id: user_id.to_i64
+ end
+ end
+ end
+end
diff --git a/src/actions/password_resets/edit.cr b/src/actions/password_resets/edit.cr
new file mode 100644
index 0000000..9408109
--- /dev/null
+++ b/src/actions/password_resets/edit.cr
@@ -0,0 +1,8 @@
+class PasswordResets::Edit < BrowserAction
+ include Auth::PasswordResets::Base
+ include Auth::PasswordResets::TokenFromSession
+
+ get "/password_resets/:user_id/edit" do
+ html NewPage, operation: ResetPassword.new, user_id: user_id.to_i64
+ end
+end
diff --git a/src/actions/password_resets/new.cr b/src/actions/password_resets/new.cr
new file mode 100644
index 0000000..5503468
--- /dev/null
+++ b/src/actions/password_resets/new.cr
@@ -0,0 +1,20 @@
+class PasswordResets::New < BrowserAction
+ include Auth::PasswordResets::Base
+
+ param token : String
+
+ get "/password_resets/:user_id" do
+ redirect_to_edit_form_without_token_param
+ end
+
+ # This is to prevent password reset tokens from being scraped in the HTTP Referer header
+ # See more info here: https://github.com/thoughtbot/clearance/pull/707
+ private def redirect_to_edit_form_without_token_param
+ make_token_available_to_future_actions
+ redirect to: PasswordResets::Edit.with(user_id)
+ end
+
+ private def make_token_available_to_future_actions
+ session.set(:password_reset_token, token)
+ end
+end
diff --git a/src/actions/sign_ins/create.cr b/src/actions/sign_ins/create.cr
new file mode 100644
index 0000000..af22588
--- /dev/null
+++ b/src/actions/sign_ins/create.cr
@@ -0,0 +1,16 @@
+class SignIns::Create < BrowserAction
+ include Auth::RedirectSignedInUsers
+
+ post "/sign_in" do
+ SignInUser.run(params) do |operation, authenticated_user|
+ if authenticated_user
+ sign_in(authenticated_user)
+ flash.success = "You're now signed in"
+ Authentic.redirect_to_originally_requested_path(self, fallback: Home::Index)
+ else
+ flash.failure = "Sign in failed"
+ html NewPage, operation: operation
+ end
+ end
+ end
+end
diff --git a/src/actions/sign_ins/delete.cr b/src/actions/sign_ins/delete.cr
new file mode 100644
index 0000000..8d34612
--- /dev/null
+++ b/src/actions/sign_ins/delete.cr
@@ -0,0 +1,7 @@
+class SignIns::Delete < BrowserAction
+ delete "/sign_out" do
+ sign_out
+ flash.info = "You have been signed out"
+ redirect to: SignIns::New
+ end
+end
diff --git a/src/actions/sign_ins/new.cr b/src/actions/sign_ins/new.cr
new file mode 100644
index 0000000..3275b40
--- /dev/null
+++ b/src/actions/sign_ins/new.cr
@@ -0,0 +1,7 @@
+class SignIns::New < BrowserAction
+ include Auth::RedirectSignedInUsers
+
+ get "/sign_in" do
+ html NewPage, operation: SignInUser.new
+ end
+end
diff --git a/src/actions/sign_ups/create.cr b/src/actions/sign_ups/create.cr
new file mode 100644
index 0000000..a291ca6
--- /dev/null
+++ b/src/actions/sign_ups/create.cr
@@ -0,0 +1,16 @@
+class SignUps::Create < BrowserAction
+ include Auth::RedirectSignedInUsers
+
+ post "/sign_up" do
+ SignUpUser.create(params) do |operation, user|
+ if user
+ flash.info = "Thanks for signing up"
+ sign_in(user)
+ redirect to: Home::Index
+ else
+ flash.info = "Couldn't sign you up"
+ html NewPage, operation: operation
+ end
+ end
+ end
+end
diff --git a/src/actions/sign_ups/new.cr b/src/actions/sign_ups/new.cr
new file mode 100644
index 0000000..2299df6
--- /dev/null
+++ b/src/actions/sign_ups/new.cr
@@ -0,0 +1,7 @@
+class SignUps::New < BrowserAction
+ include Auth::RedirectSignedInUsers
+
+ get "/sign_up" do
+ html NewPage, operation: SignUpUser.new
+ end
+end
diff --git a/src/app.cr b/src/app.cr
new file mode 100644
index 0000000..9d3ee3c
--- /dev/null
+++ b/src/app.cr
@@ -0,0 +1,26 @@
+require "./shards"
+
+# Load the asset manifest
+Lucky::AssetHelpers.load_manifest "public/mix-manifest.json"
+
+require "../config/server"
+require "./app_database"
+require "../config/**"
+require "./models/base_model"
+require "./models/mixins/**"
+require "./models/**"
+require "./queries/mixins/**"
+require "./queries/**"
+require "./operations/mixins/**"
+require "./operations/**"
+require "./serializers/base_serializer"
+require "./serializers/**"
+require "./emails/base_email"
+require "./emails/**"
+require "./actions/mixins/**"
+require "./actions/**"
+require "./components/base_component"
+require "./components/**"
+require "./pages/**"
+require "../db/migrations/**"
+require "./app_server"
diff --git a/src/app_database.cr b/src/app_database.cr
new file mode 100644
index 0000000..0efd4f5
--- /dev/null
+++ b/src/app_database.cr
@@ -0,0 +1,2 @@
+class AppDatabase < Avram::Database
+end
diff --git a/src/app_server.cr b/src/app_server.cr
new file mode 100644
index 0000000..8ec16c3
--- /dev/null
+++ b/src/app_server.cr
@@ -0,0 +1,26 @@
+class AppServer < Lucky::BaseAppServer
+ # Learn about middleware with HTTP::Handlers:
+ # https://luckyframework.org/guides/http-and-routing/http-handlers
+ def middleware : Array(HTTP::Handler)
+ [
+ Lucky::RequestIdHandler.new,
+ Lucky::ForceSSLHandler.new,
+ Lucky::HttpMethodOverrideHandler.new,
+ Lucky::LogHandler.new,
+ Lucky::ErrorHandler.new(action: Errors::Show),
+ Lucky::RemoteIpHandler.new,
+ Lucky::RouteHandler.new,
+ Lucky::StaticCompressionHandler.new("./public", file_ext: "gz", content_encoding: "gzip"),
+ Lucky::StaticFileHandler.new("./public", fallthrough: false, directory_listing: false),
+ Lucky::RouteNotFoundHandler.new,
+ ] of HTTP::Handler
+ end
+
+ def protocol
+ "http"
+ end
+
+ def listen
+ server.listen(host, port, reuse_port: false)
+ end
+end
diff --git a/src/components/.keep b/src/components/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/components/.keep
diff --git a/src/components/base_component.cr b/src/components/base_component.cr
new file mode 100644
index 0000000..c9829b4
--- /dev/null
+++ b/src/components/base_component.cr
@@ -0,0 +1,2 @@
+abstract class BaseComponent < Lucky::BaseComponent
+end
diff --git a/src/components/shared/field.cr b/src/components/shared/field.cr
new file mode 100644
index 0000000..5c32e8a
--- /dev/null
+++ b/src/components/shared/field.cr
@@ -0,0 +1,57 @@
+# This component is used to make it easier to render the same fields styles
+# throughout your app.
+#
+# Extensive documentation at: https://luckyframework.org/guides/frontend/html-forms#shared-components
+#
+# ## Basic usage:
+#
+# # Renders a text input by default and will guess the label name "Name"
+# mount Shared::Field, op.name
+# # Call any of the input methods on the block
+# mount Shared::Field, op.email, &.email_input
+# # Add other HTML attributes
+# mount Shared::Field, op.email, &.email_input(autofocus: "true")
+# # Pass an explicit label name
+# mount Shared::Field, attribute: op.username, label_text: "Your username"
+#
+# ## Customization
+#
+# You can customize this component so that fields render like you expect.
+# For example, you might wrap it in a div with a "field-wrapper" class.
+#
+# div class: "field-wrapper"
+# label_for field
+# yield field
+# mount Shared::FieldErrors, field
+# end
+#
+# You may also want to have more components if your fields look
+# different in different parts of your app, e.g. `CompactField` or
+# `InlineTextField`
+class Shared::Field(T) < BaseComponent
+ # Raises a helpful error if component receives an unpermitted attribute
+ include Lucky::CatchUnpermittedAttribute
+
+ needs attribute : Avram::PermittedAttribute(T)
+ needs label_text : String?
+
+ def render(&)
+ label_for attribute, label_text
+
+ # You can add more default options here. For example:
+ #
+ # tag_defaults field: attribute, class: "input"
+ #
+ # Will add the class "input" to the generated HTML.
+ tag_defaults field: attribute do |tag_builder|
+ yield tag_builder
+ end
+
+ mount Shared::FieldErrors, attribute
+ end
+
+ # Use a text_input by default
+ def render
+ render &.text_input
+ end
+end
diff --git a/src/components/shared/field_errors.cr b/src/components/shared/field_errors.cr
new file mode 100644
index 0000000..3f2937a
--- /dev/null
+++ b/src/components/shared/field_errors.cr
@@ -0,0 +1,16 @@
+class Shared::FieldErrors(T) < BaseComponent
+ needs attribute : Avram::PermittedAttribute(T)
+
+ # Customize the markup and styles to match your application
+ def render
+ unless attribute.valid?
+ div class: "error" do
+ text "#{label_text} #{attribute.errors.first}"
+ end
+ end
+ end
+
+ def label_text : String
+ Wordsmith::Inflector.humanize(attribute.name.to_s)
+ end
+end
diff --git a/src/components/shared/flash_messages.cr b/src/components/shared/flash_messages.cr
new file mode 100644
index 0000000..bc44440
--- /dev/null
+++ b/src/components/shared/flash_messages.cr
@@ -0,0 +1,11 @@
+class Shared::FlashMessages < BaseComponent
+ needs flash : Lucky::FlashStore
+
+ def render
+ flash.each do |flash_type, flash_message|
+ div class: "flash-#{flash_type}", flow_id: "flash" do
+ text flash_message
+ end
+ end
+ end
+end
diff --git a/src/components/shared/layout_head.cr b/src/components/shared/layout_head.cr
new file mode 100644
index 0000000..5a05331
--- /dev/null
+++ b/src/components/shared/layout_head.cr
@@ -0,0 +1,18 @@
+class Shared::LayoutHead < BaseComponent
+ needs page_title : String
+
+ def render
+ head do
+ utf8_charset
+ title "My App - #{@page_title}"
+ css_link asset("css/app.css")
+ js_link asset("js/app.js"), defer: "true"
+ csrf_meta_tags
+ responsive_meta_tag
+
+ # Development helper used with the `lucky watch` command.
+ # Reloads the browser when files are updated.
+ live_reload_connect_tag if LuckyEnv.development?
+ end
+ end
+end
diff --git a/src/css/app.scss b/src/css/app.scss
new file mode 100644
index 0000000..68b60cc
--- /dev/null
+++ b/src/css/app.scss
@@ -0,0 +1,66 @@
+// Lucky generates 3 folders to help you organize your CSS:
+//
+// - src/css/variables # Files for colors, spacing, etc.
+// - src/css/mixins # Put your mixin functions in files here
+// - src/css/components # CSS for your components
+//
+// Remember to import your new CSS files or they won't be loaded:
+//
+// @import "./variables/colors" # Imports the file in src/css/variables/_colors.scss
+//
+// Note: importing with `~` tells webpack to look in the installed npm packages
+// https://stackoverflow.com/questions/39535760/what-does-a-tilde-in-a-css-url-do
+
+@import 'modern-normalize/modern-normalize.css';
+// Add your own components and import them like this:
+//
+// @import "components/my_new_component";
+
+// Default Lucky styles.
+// Delete these when you're ready to bring in your own CSS.
+body {
+ font-family: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
+ Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
+ sans-serif;
+ margin: 0 auto;
+ max-width: 800px;
+ padding: 20px 40px;
+}
+
+label, input {
+ display: flex;
+}
+
+label {
+ font-weight: 500;
+}
+
+[type='color'],
+[type='date'],
+[type='datetime'],
+[type='datetime-local'],
+[type='email'],
+[type='month'],
+[type='number'],
+[type='password'],
+[type='search'],
+[type='tel'],
+[type='text'],
+[type='time'],
+[type='url'],
+[type='week'],
+input:not([type]),
+textarea {
+ border-radius: 3px;
+ border: 1px solid #bbb;
+ margin: 7px 0 14px 0;
+ max-width: 400px;
+ padding: 8px 6px;
+ width: 100%;
+}
+
+[type='submit'] {
+ font-weight: 900;
+ margin: 9px 0;
+ padding: 6px 9px;
+}
diff --git a/src/diskfloppydotme.cr b/src/diskfloppydotme.cr
new file mode 100644
index 0000000..68e1a8d
--- /dev/null
+++ b/src/diskfloppydotme.cr
@@ -0,0 +1,6 @@
+# Typically you will not use or modify this file. 'shards build' and some
+# other crystal tools will sometimes use this.
+#
+# When this file is compiled/run it will require and run 'start_server',
+# which as its name implies will start the server for you app.
+require "./start_server"
diff --git a/src/emails/.keep b/src/emails/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/emails/.keep
diff --git a/src/emails/base_email.cr b/src/emails/base_email.cr
new file mode 100644
index 0000000..656f4f1
--- /dev/null
+++ b/src/emails/base_email.cr
@@ -0,0 +1,15 @@
+# Learn about sending emails
+# https://luckyframework.org/guides/emails/sending-emails-with-carbon
+abstract class BaseEmail < Carbon::Email
+ # You can add defaults using the 'inherited' hook
+ #
+ # Example:
+ #
+ # macro inherited
+ # from default_from
+ # end
+ #
+ # def default_from
+ # Carbon::Address.new("support@app.com")
+ # end
+end
diff --git a/src/emails/password_reset_request_email.cr b/src/emails/password_reset_request_email.cr
new file mode 100644
index 0000000..a41c8ba
--- /dev/null
+++ b/src/emails/password_reset_request_email.cr
@@ -0,0 +1,13 @@
+class PasswordResetRequestEmail < BaseEmail
+ Habitat.create { setting stubbed_token : String? }
+ delegate stubbed_token, to: :settings
+
+ def initialize(@user : User)
+ @token = stubbed_token || Authentic.generate_password_reset_token(@user)
+ end
+
+ to @user
+ from "myapp@support.com" # or set a default in src/emails/base_email.cr
+ subject "Reset your password"
+ templates html, text
+end
diff --git a/src/emails/templates/password_reset_request_email/html.ecr b/src/emails/templates/password_reset_request_email/html.ecr
new file mode 100644
index 0000000..00c24fc
--- /dev/null
+++ b/src/emails/templates/password_reset_request_email/html.ecr
@@ -0,0 +1,3 @@
+<h1>Please reset your password</h1>
+
+<a href="<%= PasswordResets::New.url(@user.id, @token) %>">Reset password</a>
diff --git a/src/emails/templates/password_reset_request_email/text.ecr b/src/emails/templates/password_reset_request_email/text.ecr
new file mode 100644
index 0000000..7a7a0ab
--- /dev/null
+++ b/src/emails/templates/password_reset_request_email/text.ecr
@@ -0,0 +1,3 @@
+Please reset your password:
+
+<%= PasswordResets::New.url(@user.id, @token) %>
diff --git a/src/js/app.js b/src/js/app.js
new file mode 100644
index 0000000..cca98fa
--- /dev/null
+++ b/src/js/app.js
@@ -0,0 +1,7 @@
+/* eslint no-console:0 */
+
+// Rails Unobtrusive JavaScript (UJS) is *required* for links in Lucky that use DELETE, POST and PUT.
+// Though it says "Rails" it actually works with any framework.
+import Rails from "@rails/ujs";
+Rails.start();
+
diff --git a/src/models/base_model.cr b/src/models/base_model.cr
new file mode 100644
index 0000000..6bafeb8
--- /dev/null
+++ b/src/models/base_model.cr
@@ -0,0 +1,5 @@
+abstract class BaseModel < Avram::Model
+ def self.database : Avram::Database.class
+ AppDatabase
+ end
+end
diff --git a/src/models/mixins/.keep b/src/models/mixins/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/models/mixins/.keep
diff --git a/src/models/user.cr b/src/models/user.cr
new file mode 100644
index 0000000..39729bb
--- /dev/null
+++ b/src/models/user.cr
@@ -0,0 +1,13 @@
+class User < BaseModel
+ include Carbon::Emailable
+ include Authentic::PasswordAuthenticatable
+
+ table do
+ column email : String
+ column encrypted_password : String
+ end
+
+ def emailable : Carbon::Address
+ Carbon::Address.new(email)
+ end
+end
diff --git a/src/models/user_token.cr b/src/models/user_token.cr
new file mode 100644
index 0000000..6586303
--- /dev/null
+++ b/src/models/user_token.cr
@@ -0,0 +1,30 @@
+# Generates and decodes JSON Web Tokens for Authenticating users.
+class UserToken
+ Habitat.create { setting stubbed_token : String? }
+ ALGORITHM = JWT::Algorithm::HS256
+
+ def self.generate(user : User) : String
+ payload = {"user_id" => user.id}
+
+ settings.stubbed_token || create_token(payload)
+ end
+
+ def self.create_token(payload)
+ JWT.encode(payload, Lucky::Server.settings.secret_key_base, ALGORITHM)
+ end
+
+ def self.decode_user_id(token : String) : Int64?
+ payload, _header = JWT.decode(token, Lucky::Server.settings.secret_key_base, ALGORITHM)
+ payload["user_id"].to_s.to_i64
+ rescue e : JWT::Error
+ Lucky::Log.dexter.error { {jwt_decode_error: e.message} }
+ nil
+ end
+
+ # Used in tests to return a fake token to test against.
+ def self.stub_token(token : String, &)
+ temp_config(stubbed_token: token) do
+ yield
+ end
+ end
+end
diff --git a/src/operations/.keep b/src/operations/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/operations/.keep
diff --git a/src/operations/mixins/.keep b/src/operations/mixins/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/operations/mixins/.keep
diff --git a/src/operations/mixins/password_validations.cr b/src/operations/mixins/password_validations.cr
new file mode 100644
index 0000000..c56b975
--- /dev/null
+++ b/src/operations/mixins/password_validations.cr
@@ -0,0 +1,12 @@
+module PasswordValidations
+ macro included
+ before_save run_password_validations
+ end
+
+ private def run_password_validations
+ validate_required password, password_confirmation
+ validate_confirmation_of password, with: password_confirmation
+ # 72 is a limitation of BCrypt
+ validate_size_of password, min: 6, max: 72
+ end
+end
diff --git a/src/operations/mixins/user_from_email.cr b/src/operations/mixins/user_from_email.cr
new file mode 100644
index 0000000..862fa9a
--- /dev/null
+++ b/src/operations/mixins/user_from_email.cr
@@ -0,0 +1,7 @@
+module UserFromEmail
+ private def user_from_email : User?
+ email.value.try do |value|
+ UserQuery.new.email(value).first?
+ end
+ end
+end
diff --git a/src/operations/request_password_reset.cr b/src/operations/request_password_reset.cr
new file mode 100644
index 0000000..4941aa7
--- /dev/null
+++ b/src/operations/request_password_reset.cr
@@ -0,0 +1,25 @@
+class RequestPasswordReset < Avram::Operation
+ # You can modify this in src/operations/mixins/user_from_email.cr
+ include UserFromEmail
+
+ attribute email : String
+
+ # Run validations and yield the operation and the user if valid
+ def run
+ user = user_from_email
+ validate(user)
+
+ if valid?
+ user
+ else
+ nil
+ end
+ end
+
+ def validate(user : User?)
+ validate_required email
+ if user.nil?
+ email.add_error "is not in our system"
+ end
+ end
+end
diff --git a/src/operations/reset_password.cr b/src/operations/reset_password.cr
new file mode 100644
index 0000000..3bdd3c8
--- /dev/null
+++ b/src/operations/reset_password.cr
@@ -0,0 +1,11 @@
+class ResetPassword < User::SaveOperation
+ # Change password validations in src/operations/mixins/password_validations.cr
+ include PasswordValidations
+
+ attribute password : String
+ attribute password_confirmation : String
+
+ before_save do
+ Authentic.copy_and_encrypt password, to: encrypted_password
+ end
+end
diff --git a/src/operations/sign_in_user.cr b/src/operations/sign_in_user.cr
new file mode 100644
index 0000000..de80342
--- /dev/null
+++ b/src/operations/sign_in_user.cr
@@ -0,0 +1,40 @@
+class SignInUser < Avram::Operation
+ param_key :user
+ # You can modify this in src/operations/mixins/user_from_email.cr
+ include UserFromEmail
+
+ attribute email : String
+ attribute password : String
+
+ # Run validations and yields the operation and the user if valid
+ def run
+ user = user_from_email
+ validate_credentials(user)
+
+ if valid?
+ user
+ else
+ nil
+ end
+ end
+
+ # `validate_credentials` determines if a user can sign in.
+ #
+ # If desired, you can add additional checks in this method, e.g.
+ #
+ # if user.locked?
+ # email.add_error "is locked out"
+ # end
+ private def validate_credentials(user)
+ if user
+ unless Authentic.correct_password?(user, password.value.to_s)
+ password.add_error "is wrong"
+ end
+ else
+ # Usually ok to say that an email is not in the system:
+ # https://kev.inburke.com/kevin/invalid-username-or-password-useless/
+ # https://github.com/luckyframework/lucky_cli/issues/192
+ email.add_error "is not in our system"
+ end
+ end
+end
diff --git a/src/operations/sign_up_user.cr b/src/operations/sign_up_user.cr
new file mode 100644
index 0000000..8c46fad
--- /dev/null
+++ b/src/operations/sign_up_user.cr
@@ -0,0 +1,14 @@
+class SignUpUser < User::SaveOperation
+ param_key :user
+ # Change password validations in src/operations/mixins/password_validations.cr
+ include PasswordValidations
+
+ permit_columns email
+ attribute password : String
+ attribute password_confirmation : String
+
+ before_save do
+ validate_uniqueness_of email
+ Authentic.copy_and_encrypt(password, to: encrypted_password) if password.valid?
+ end
+end
diff --git a/src/pages/auth_layout.cr b/src/pages/auth_layout.cr
new file mode 100644
index 0000000..c2ac1b0
--- /dev/null
+++ b/src/pages/auth_layout.cr
@@ -0,0 +1,27 @@
+abstract class AuthLayout
+ include Lucky::HTMLPage
+
+ abstract def content
+ abstract def page_title
+
+ # The default page title. It is passed to `Shared::LayoutHead`.
+ #
+ # Add a `page_title` method to pages to override it. You can also remove
+ # This method so every page is required to have its own page title.
+ def page_title
+ "Welcome"
+ end
+
+ def render
+ html_doctype
+
+ html lang: "en" do
+ mount Shared::LayoutHead, page_title: page_title
+
+ body do
+ mount Shared::FlashMessages, context.flash
+ content
+ end
+ end
+ end
+end
diff --git a/src/pages/errors/show_page.cr b/src/pages/errors/show_page.cr
new file mode 100644
index 0000000..e7636de
--- /dev/null
+++ b/src/pages/errors/show_page.cr
@@ -0,0 +1,93 @@
+class Errors::ShowPage
+ include Lucky::HTMLPage
+
+ needs message : String
+ needs status_code : Int32
+
+ def render
+ html_doctype
+ html lang: "en" do
+ head do
+ utf8_charset
+ title "Something went wrong"
+ load_lato_font
+ normalize_styles
+ error_page_styles
+ end
+
+ body do
+ div class: "container" do
+ h2 status_code, class: "status-code"
+ h1 message, class: "message"
+
+ ul class: "helpful-links" do
+ li do
+ a "Try heading back to home", href: "/", class: "helpful-link"
+ end
+ end
+ end
+ end
+ end
+ end
+
+ def load_lato_font
+ css_link "https://fonts.googleapis.com/css?family=Lato"
+ end
+
+ def normalize_styles
+ style <<-CSS
+ /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}
+ CSS
+ end
+
+ def error_page_styles
+ style <<-CSS
+ body {
+ background-color: #f5f5f5;
+ color: #000;
+ font-family: 'Lato', sans-serif;
+ padding-top: 100px;
+ }
+
+ .helpful-links {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ .helpful-link {
+ color: #15A38B;
+ }
+
+ .status-code {
+ opacity: 0.4;
+ font-size: 26px;
+ font-weight: normal;
+ }
+
+ .message {
+ font-size: 34px;
+ line-height: 56px;
+ font-weight: normal;
+ }
+
+ .container {
+ margin: 0 auto;
+ max-width: 450px;
+ padding: 55px;
+ }
+
+ @media only screen and (max-width: 500px) {
+ .status-code {
+ font-size: 18px;
+ }
+
+ .message {
+ font-size: 26px;
+ line-height: 40px;
+ margin: 20px 0 35px 0;
+ }
+ }
+ CSS
+ end
+end
diff --git a/src/pages/main_layout.cr b/src/pages/main_layout.cr
new file mode 100644
index 0000000..06a1ed7
--- /dev/null
+++ b/src/pages/main_layout.cr
@@ -0,0 +1,45 @@
+abstract class MainLayout
+ include Lucky::HTMLPage
+
+ # 'needs current_user : User' makes it so that the current_user
+ # is always required for pages using MainLayout
+ needs current_user : User
+
+ abstract def content
+ abstract def page_title
+
+ # MainLayout defines a default 'page_title'.
+ #
+ # Add a 'page_title' method to your indivual pages to customize each page's
+ # title.
+ #
+ # Or, if you want to require every page to set a title, change the
+ # 'page_title' method in this layout to:
+ #
+ # abstract def page_title : String
+ #
+ # This will force pages to define their own 'page_title' method.
+ def page_title
+ "Welcome"
+ end
+
+ def render
+ html_doctype
+
+ html lang: "en" do
+ mount Shared::LayoutHead, page_title: page_title
+
+ body do
+ mount Shared::FlashMessages, context.flash
+ render_signed_in_user
+ content
+ end
+ end
+ end
+
+ private def render_signed_in_user
+ text current_user.email
+ text " - "
+ link "Sign out", to: SignIns::Delete, flow_id: "sign-out-button"
+ end
+end
diff --git a/src/pages/me/show_page.cr b/src/pages/me/show_page.cr
new file mode 100644
index 0000000..6a6bd87
--- /dev/null
+++ b/src/pages/me/show_page.cr
@@ -0,0 +1,21 @@
+class Me::ShowPage < MainLayout
+ def content
+ h1 "This is your profile"
+ h3 "Email: #{@current_user.email}"
+ helpful_tips
+ end
+
+ private def helpful_tips
+ h3 "Next, you may want to:"
+ ul do
+ li { link_to_authentication_guides }
+ li "Modify this page: src/pages/me/show_page.cr"
+ li "Change where you go after sign in: src/actions/home/index.cr"
+ end
+ end
+
+ private def link_to_authentication_guides
+ a "Check out the authentication guides",
+ href: "https://luckyframework.org/guides/authentication"
+ end
+end
diff --git a/src/pages/password_reset_requests/new_page.cr b/src/pages/password_reset_requests/new_page.cr
new file mode 100644
index 0000000..368784c
--- /dev/null
+++ b/src/pages/password_reset_requests/new_page.cr
@@ -0,0 +1,15 @@
+class PasswordResetRequests::NewPage < AuthLayout
+ needs operation : RequestPasswordReset
+
+ def content
+ h1 "Reset your password"
+ render_form(@operation)
+ end
+
+ private def render_form(op)
+ form_for PasswordResetRequests::Create do
+ mount Shared::Field, attribute: op.email, label_text: "Email", &.email_input
+ submit "Reset Password", flow_id: "request-password-reset-button"
+ end
+ end
+end
diff --git a/src/pages/password_resets/new_page.cr b/src/pages/password_resets/new_page.cr
new file mode 100644
index 0000000..16a6635
--- /dev/null
+++ b/src/pages/password_resets/new_page.cr
@@ -0,0 +1,18 @@
+class PasswordResets::NewPage < AuthLayout
+ needs operation : ResetPassword
+ needs user_id : Int64
+
+ def content
+ h1 "Reset your password"
+ render_password_reset_form(@operation)
+ end
+
+ private def render_password_reset_form(op)
+ form_for PasswordResets::Create.with(@user_id) do
+ mount Shared::Field, attribute: op.password, label_text: "Password", &.password_input(autofocus: "true")
+ mount Shared::Field, attribute: op.password_confirmation, label_text: "Confirm Password", &.password_input
+
+ submit "Update Password", flow_id: "update-password-button"
+ end
+ end
+end
diff --git a/src/pages/sign_ins/new_page.cr b/src/pages/sign_ins/new_page.cr
new file mode 100644
index 0000000..1018813
--- /dev/null
+++ b/src/pages/sign_ins/new_page.cr
@@ -0,0 +1,23 @@
+class SignIns::NewPage < AuthLayout
+ needs operation : SignInUser
+
+ def content
+ h1 "Sign In"
+ render_sign_in_form(@operation)
+ end
+
+ private def render_sign_in_form(op)
+ form_for SignIns::Create do
+ sign_in_fields(op)
+ submit "Sign In", flow_id: "sign-in-button"
+ end
+ link "Reset password", to: PasswordResetRequests::New
+ text " | "
+ link "Sign up", to: SignUps::New
+ end
+
+ private def sign_in_fields(op)
+ mount Shared::Field, attribute: op.email, label_text: "Email", &.email_input(autofocus: "true")
+ mount Shared::Field, attribute: op.password, label_text: "Password", &.password_input
+ end
+end
diff --git a/src/pages/sign_ups/new_page.cr b/src/pages/sign_ups/new_page.cr
new file mode 100644
index 0000000..24f6cb2
--- /dev/null
+++ b/src/pages/sign_ups/new_page.cr
@@ -0,0 +1,22 @@
+class SignUps::NewPage < AuthLayout
+ needs operation : SignUpUser
+
+ def content
+ h1 "Sign Up"
+ render_sign_up_form(@operation)
+ end
+
+ private def render_sign_up_form(op)
+ form_for SignUps::Create do
+ sign_up_fields(op)
+ submit "Sign Up", flow_id: "sign-up-button"
+ end
+ link "Sign in instead", to: SignIns::New
+ end
+
+ private def sign_up_fields(op)
+ mount Shared::Field, attribute: op.email, label_text: "Email", &.email_input(autofocus: "true")
+ mount Shared::Field, attribute: op.password, label_text: "Password", &.password_input
+ mount Shared::Field, attribute: op.password_confirmation, label_text: "Confirm Password", &.password_input
+ end
+end
diff --git a/src/queries/.keep b/src/queries/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/queries/.keep
diff --git a/src/queries/mixins/.keep b/src/queries/mixins/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/queries/mixins/.keep
diff --git a/src/queries/user_query.cr b/src/queries/user_query.cr
new file mode 100644
index 0000000..8a7e9a7
--- /dev/null
+++ b/src/queries/user_query.cr
@@ -0,0 +1,2 @@
+class UserQuery < User::BaseQuery
+end
diff --git a/src/serializers/.keep b/src/serializers/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/serializers/.keep
diff --git a/src/serializers/base_serializer.cr b/src/serializers/base_serializer.cr
new file mode 100644
index 0000000..3ad0a66
--- /dev/null
+++ b/src/serializers/base_serializer.cr
@@ -0,0 +1,7 @@
+abstract class BaseSerializer < Lucky::Serializer
+ def self.for_collection(collection : Enumerable, *args, **named_args)
+ collection.map do |object|
+ new(object, *args, **named_args)
+ end
+ end
+end
diff --git a/src/serializers/error_serializer.cr b/src/serializers/error_serializer.cr
new file mode 100644
index 0000000..21a53aa
--- /dev/null
+++ b/src/serializers/error_serializer.cr
@@ -0,0 +1,14 @@
+# This is the default error serializer generated by Lucky.
+# Feel free to customize it in any way you like.
+class ErrorSerializer < BaseSerializer
+ def initialize(
+ @message : String,
+ @details : String? = nil,
+ @param : String? = nil # so you can track which param (if any) caused the problem
+ )
+ end
+
+ def render
+ {message: @message, param: @param, details: @details}
+ end
+end
diff --git a/src/serializers/user_serializer.cr b/src/serializers/user_serializer.cr
new file mode 100644
index 0000000..1a86f14
--- /dev/null
+++ b/src/serializers/user_serializer.cr
@@ -0,0 +1,8 @@
+class UserSerializer < BaseSerializer
+ def initialize(@user : User)
+ end
+
+ def render
+ {email: @user.email}
+ end
+end
diff --git a/src/shards.cr b/src/shards.cr
new file mode 100644
index 0000000..7cadec1
--- /dev/null
+++ b/src/shards.cr
@@ -0,0 +1,10 @@
+# Load .env file before any other config or app code
+require "lucky_env"
+LuckyEnv.load?(".env")
+
+# Require your shards here
+require "lucky"
+require "avram/lucky"
+require "carbon"
+require "authentic"
+require "jwt"
diff --git a/src/start_server.cr b/src/start_server.cr
new file mode 100644
index 0000000..9df5d1f
--- /dev/null
+++ b/src/start_server.cr
@@ -0,0 +1,16 @@
+require "./app"
+
+Habitat.raise_if_missing_settings!
+
+if LuckyEnv.development?
+ Avram::Migrator::Runner.new.ensure_migrated!
+ Avram::SchemaEnforcer.ensure_correct_column_mappings!
+end
+
+app_server = AppServer.new
+
+Signal::INT.trap do
+ app_server.close
+end
+
+app_server.listen