Better turn that down a bit. We don't want to deafen them.
",
- "
Director:
Sorry, my mistake.
",
- "
Electrics:
This equipment is more complicated than we need.
",
- "
Performer:
I really think my big scene should be cut.
",
- "
SM:
Can we doo that scene change again please?",
- "
LX designer:
Bit more light from those big chaps at the side. Yes that's right, the ones on stalks whatever they are called.
",
- "
Electrics:
All the equipment works perfectly.
",
- "
Musicians:
So what if that's the end of a call. Let's just finish this bit off.
",
- "
Wardrobe:
Now, when exactly is the first dress rehearsal?",
- "
Workshop:
I don't want anyone to know, but if you insist then yes, I admit it, I have just done an all-nighter.
",
- "
Performer:
This costume is so comfortable.
",
- "
Admin:
The level of overtime payments here are simply unacceptable. Our backstage staff deserve better.
",
- "
Box Office:
Comps? No problem.
",
- "
Set Designer:
You're right, it looks dreadful.
",
- "
Flyman:
No, my lips are sealed. What I may or may not have seen remains a secret.
",
- "
Electrics:
That had nothing to do with the computer, it was my fault.
",
- "
Crew:
No, no, I'm sure that's our job.
",
- "
SMgt:
Thanks, but I don't drink",
- "
Performer:
Let me stand down here with my back to the audience.
",
- "
Chippie:
I can't really manage those big fast power tools myself.
",
- "
Chippie:
I prefer to use these little hand drills.
",
- "
All:
Let's go and ask the Production Manager. He'll know.
"
-]
-
-// Generate a random index into the array
-const randomIndex = Math.floor(Math.random() * neverSaid.length);
-
-// Use document.write to output the random string
-document.write(neverSaid[randomIndex]);
diff --git a/resources/js/schemeSwap.js b/resources/js/schemeSwap.js
deleted file mode 100644
index 3baa09b..0000000
--- a/resources/js/schemeSwap.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * Retrieves a cookies value
- * @param {string} cname Cookie name
- * @returns {string} Cookie value
- */
-function getCookie(cname) {
- let name = cname + "=";
- let decodedCookie = decodeURIComponent(document.cookie);
- let ca = decodedCookie.split(';');
- for(let i = 0; i
- Bookmarks
- @foreach($categories as $category)
-
The calculator measures 74.2mm x 135mm x 22.2mm. It weighs 86g without the battery installed, which is a 9v PP3-type battery. Rather than the usual press-stud type holder, the housing has two metal slide clips. There is also what I assume to be a sponge at one end which is supposed to aid in holding the battery in, however it appears to have gone completely hard and I will most likely replace it in the future. There's small adaptor hole at the top, of which the input isn't specified (though it's generally agreed that it's 4.5v centre-positive).
-
The case is black & silvery colored with a thin brushed metallic front panel. The eight-digit bubble display has an absolutely terrible viewing angle, which means you either have to be holding it under your coat or against your face to read it!
- The keypad is particularly strange in the way that it has 3 cancel buttons, [CE], [C] and [CA], while the [CS] button is a Clear Sign button, not another cancel! The keys themselves are particularly stiff, and you really have to push them to get them to register. Many 880s suffered something referred to as the "pseudo fixed decimal bug" where, if you typed in 1 + 1.00 = , it would display 2.00 instead of the expected 2
-
-
-
diff --git a/resources/views/music.blade.php b/resources/views/music.blade.php
deleted file mode 100644
index 92e5a90..0000000
--- a/resources/views/music.blade.php
+++ /dev/null
@@ -1,5 +0,0 @@
-
- Music
-
-
-
diff --git a/routes/api.php b/routes/api.php
deleted file mode 100644
index fa84410..0000000
--- a/routes/api.php
+++ /dev/null
@@ -1,15 +0,0 @@
-middleware('rate_limit');
diff --git a/script/helpers/function_helpers b/script/helpers/function_helpers
new file mode 100644
index 0000000..388fa67
--- /dev/null
+++ b/script/helpers/function_helpers
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+
+# This file contains a set of functions used as helpers
+# for various tasks. Read the examples for each one for
+# more information. Feel free to put any additional helper
+# functions you may need for your app
+
+
+# Returns true if the command $1 is not found
+# example:
+# if command_not_found "yarn"; then
+# echo "no yarn"
+# fi
+command_not_found() {
+ ! command -v $1 > /dev/null
+ return $?
+}
+
+# Returns true if the command $1 is not running
+# You must supply the full command to check as an argument
+# example:
+# if command_not_running "redis-cli ping"; then
+# print_error "Redis is not running"
+# fi
+command_not_running() {
+ $1
+ if [ $? -ne 0 ]; then
+ true
+ else
+ false
+ fi
+}
+
+# Returns true if the OS is macOS
+# example:
+# if is_mac; then
+# echo "do mac stuff"
+# fi
+is_mac() {
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ true
+ else
+ false
+ fi
+}
+
+# Returns true if the OS is linux based
+# example:
+# if is_linux; then
+# echo "do linux stuff"
+# fi
+is_linux() {
+ if [[ "$OSTYPE" == "linux"* ]]; then
+ true
+ else
+ false
+ fi
+}
+
+# Prints error and exit.
+# example:
+# print_error "Redis is not running. Run it with some_command"
+print_error() {
+ printf "${BOLD_RED_COLOR}There is a problem with your system setup:\n\n"
+ printf "${BOLD_RED_COLOR}$1 \n\n" | indent
+ exit 1
+}
diff --git a/script/helpers/text_helpers b/script/helpers/text_helpers
new file mode 100644
index 0000000..34b77a8
--- /dev/null
+++ b/script/helpers/text_helpers
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+
+# This file contains a set of functions used to format text,
+# and make printing text a little easier. Feel free to put
+# any additional functions you need for formatting your shell
+# output text.
+
+# Colors
+BOLD_RED_COLOR="\e[1m\e[31m"
+
+# Indents the text 2 spaces
+# example:
+# printf "Hello" | indent
+indent() {
+ while read LINE; do
+ echo " $LINE" || true
+ done
+}
+
+# Prints out an arrow to your custom notice
+# example:
+# notice "Installing new magic"
+notice() {
+ printf "\n▸ $1\n"
+}
+
+# Prints out a check mark and Done.
+# example:
+# print_done
+print_done() {
+ printf "✔ Done\n" | indent
+}
diff --git a/script/setup b/script/setup
new file mode 100755
index 0000000..bf0bc31
--- /dev/null
+++ b/script/setup
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+
+# Exit if any subcommand fails
+set -e
+set -o pipefail
+
+source script/helpers/text_helpers
+
+
+notice "Running System Check"
+./script/system_check
+print_done
+
+notice "Installing node dependencies"
+yarn install --no-progress | indent
+
+notice "Compiling assets"
+yarn dev | indent
+
+print_done
+
+notice "Installing shards"
+shards install --ignore-crystal-version | indent
+
+if [ ! -f ".env" ]; then
+ notice "No .env found. Creating one."
+ touch .env
+ print_done
+fi
+
+notice "Creating the database"
+lucky db.create | indent
+
+notice "Verifying postgres connection"
+lucky db.verify_connection | indent
+
+notice "Migrating the database"
+lucky db.migrate | indent
+
+notice "Seeding the database with required and sample records"
+lucky db.seed.required_data | indent
+lucky db.seed.sample_data | indent
+
+print_done
+notice "Run 'lucky dev' to start the app"
diff --git a/script/system_check b/script/system_check
new file mode 100755
index 0000000..c27c926
--- /dev/null
+++ b/script/system_check
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+
+source script/helpers/text_helpers
+source script/helpers/function_helpers
+
+# Use this script to check the system for required tools and process that your app needs.
+# A few helper functions are provided to make writing bash a little easier. See the
+# script/helpers/function_helpers file for more examples.
+#
+# A few examples you might use here:
+# * 'lucky db.verify_connection' to test postgres can be connected
+# * Checking that elasticsearch, redis, or postgres is installed and/or booted
+# * Note: Booting additional processes for things like mail, background jobs, etc...
+# should go in your Procfile.dev.
+
+if command_not_found "yarn"; then
+ print_error "Yarn is not installed\n See https://yarnpkg.com/lang/en/docs/install/ for install instructions."
+fi
+
+if command_not_found "createdb"; then
+ MSG="Please install the postgres CLI tools, then try again."
+ if is_mac; then
+ MSG="$MSG\nIf you're using Postgres.app, see https://postgresapp.com/documentation/cli-tools.html."
+ fi
+ MSG="$MSG\nSee https://www.postgresql.org/docs/current/tutorial-install.html for install instructions."
+
+ print_error "$MSG"
+fi
+
+
+## CUSTOM PRE-BOOT CHECKS ##
+# example:
+# if command_not_running "redis-cli ping"; then
+# print_error "Redis is not running."
+# fi
+
+
diff --git a/scripts/updatecache.bat b/scripts/updatecache.bat
deleted file mode 100644
index 0245663..0000000
--- a/scripts/updatecache.bat
+++ /dev/null
@@ -1,6 +0,0 @@
-@echo off
-php artisan config:cache
-php artisan route:cache
-php artisan view:cache
-php artisan event:cache
-php artisan cache:clear
diff --git a/scripts/updatecache.sh b/scripts/updatecache.sh
deleted file mode 100755
index b4acd8c..0000000
--- a/scripts/updatecache.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-php artisan config:cache
-php artisan route:cache
-php artisan view:cache
-php artisan event:cache
-php artisan cache:clear
diff --git a/shard.lock b/shard.lock
new file mode 100644
index 0000000..be795f0
--- /dev/null
+++ b/shard.lock
@@ -0,0 +1,134 @@
+version: 2.0
+shards:
+ authentic:
+ git: https://github.com/luckyframework/authentic.git
+ version: 1.0.0
+
+ avram:
+ git: https://github.com/luckyframework/avram.git
+ version: 1.2.0
+
+ backtracer:
+ git: https://github.com/sija/backtracer.cr.git
+ version: 1.2.2
+
+ bindata:
+ git: https://github.com/spider-gazelle/bindata.git
+ version: 2.1.0
+
+ cadmium_transliterator:
+ git: https://github.com/cadmiumcr/transliterator.git
+ version: 0.1.0+git.commit.46c4c14594057dbcfaf27e7e7c8c164d3f0ce3f1
+
+ carbon:
+ git: https://github.com/luckyframework/carbon.git
+ version: 0.5.1
+
+ carbon_sendgrid_adapter:
+ git: https://github.com/luckyframework/carbon_sendgrid_adapter.git
+ version: 0.5.1
+
+ cry:
+ git: https://github.com/luckyframework/cry.git
+ version: 0.4.3
+
+ crystar:
+ git: https://github.com/naqvis/crystar.git
+ version: 0.3.1
+
+ db:
+ git: https://github.com/crystal-lang/crystal-db.git
+ version: 0.13.1
+
+ dexter:
+ git: https://github.com/luckyframework/dexter.git
+ version: 0.3.4
+
+ exception_page:
+ git: https://github.com/crystal-loot/exception_page.git
+ version: 0.4.1
+
+ fnv:
+ git: https://github.com/naqvis/crystal-fnv.git
+ version: 0.1.3
+
+ habitat:
+ git: https://github.com/luckyframework/habitat.git
+ version: 0.4.9
+
+ html5:
+ git: https://github.com/naqvis/crystal-html5.git
+ version: 0.5.0
+
+ jwt:
+ git: https://github.com/crystal-community/jwt.git
+ version: 1.6.1
+
+ lucky:
+ git: https://github.com/luckyframework/lucky.git
+ version: 1.2.0
+
+ lucky_cache:
+ git: https://github.com/luckyframework/lucky_cache.git
+ version: 0.1.1
+
+ lucky_env:
+ git: https://github.com/luckyframework/lucky_env.git
+ version: 0.2.0
+
+ lucky_flow:
+ git: https://github.com/luckyframework/lucky_flow.git
+ version: 0.10.0
+
+ lucky_router:
+ git: https://github.com/luckyframework/lucky_router.git
+ version: 0.5.2
+
+ lucky_task:
+ git: https://github.com/luckyframework/lucky_task.git
+ version: 0.3.0
+
+ lucky_template:
+ git: https://github.com/luckyframework/lucky_template.git
+ version: 0.2.0
+
+ openssl_ext:
+ git: https://github.com/spider-gazelle/openssl_ext.git
+ version: 2.4.4
+
+ pg:
+ git: https://github.com/will/crystal-pg.git
+ version: 0.28.0
+
+ pulsar:
+ git: https://github.com/luckyframework/pulsar.git
+ version: 0.2.3
+
+ selenium:
+ git: https://github.com/crystal-loot/selenium.cr.git
+ version: 0.12.1
+
+ shell-table:
+ git: https://github.com/luckyframework/shell-table.cr.git
+ version: 0.9.3
+
+ splay_tree_map:
+ git: https://github.com/wyhaines/splay_tree_map.cr.git
+ version: 0.3.0
+
+ webdrivers:
+ git: https://github.com/crystal-loot/webdrivers.cr.git
+ version: 0.4.3
+
+ webless:
+ git: https://github.com/crystal-loot/webless.git
+ version: 0.1.0
+
+ wordsmith:
+ git: https://github.com/luckyframework/wordsmith.git
+ version: 0.4.0
+
+ xpath2:
+ git: https://github.com/naqvis/crystal-xpath2.git
+ version: 0.1.3
+
diff --git a/shard.yml b/shard.yml
new file mode 100644
index 0000000..261b3c7
--- /dev/null
+++ b/shard.yml
@@ -0,0 +1,36 @@
+---
+name: diskfloppydotme
+version: 0.1.0
+targets:
+ diskfloppydotme:
+ main: src/diskfloppydotme.cr
+crystal: '>= 1.13.2'
+dependencies:
+ lucky:
+ github: luckyframework/lucky
+ version: ~> 1.2.0
+ avram:
+ github: luckyframework/avram
+ version: ~> 1.2.0
+ carbon:
+ github: luckyframework/carbon
+ version: ~> 0.5.1
+ carbon_sendgrid_adapter:
+ github: luckyframework/carbon_sendgrid_adapter
+ version: ~> 0.5.0
+ lucky_env:
+ github: luckyframework/lucky_env
+ version: ~> 0.2.0
+ lucky_task:
+ github: luckyframework/lucky_task
+ version: ~> 0.3.0
+ authentic:
+ github: luckyframework/authentic
+ version: '>= 1.0.0, < 2.0.0'
+ jwt:
+ github: crystal-community/jwt
+ version: ~> 1.6.1
+development_dependencies:
+ lucky_flow:
+ github: luckyframework/lucky_flow
+ version: ~> 0.10.0
diff --git a/spec/flows/.keep b/spec/flows/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/spec/flows/authentication_spec.cr b/spec/flows/authentication_spec.cr
new file mode 100644
index 0000000..b22f93c
--- /dev/null
+++ b/spec/flows/authentication_spec.cr
@@ -0,0 +1,31 @@
+require "../spec_helper"
+
+describe "Authentication flow", tags: "flow" do
+ it "works" do
+ flow = AuthenticationFlow.new("test@example.com")
+
+ flow.sign_up "password"
+ flow.should_be_signed_in
+ flow.sign_out
+ flow.sign_in "wrong-password"
+ flow.should_have_password_error
+ flow.sign_in "password"
+ flow.should_be_signed_in
+ end
+
+ # This is to show you how to sign in as a user during tests.
+ # Use the `visit` method's `as` option in your tests to sign in as that user.
+ #
+ # Feel free to delete this once you have other tests using the 'as' option.
+ it "allows sign in through backdoor when testing" do
+ user = UserFactory.create
+ flow = BaseFlow.new
+
+ flow.visit Me::Show, as: user
+ should_be_signed_in(flow)
+ end
+end
+
+private def should_be_signed_in(flow)
+ flow.should have_element("@sign-out-button")
+end
diff --git a/spec/flows/reset_password_spec.cr b/spec/flows/reset_password_spec.cr
new file mode 100644
index 0000000..b28af34
--- /dev/null
+++ b/spec/flows/reset_password_spec.cr
@@ -0,0 +1,18 @@
+require "../spec_helper"
+
+describe "Reset password flow", tags: "flow" do
+ it "works" do
+ user = UserFactory.create
+ flow = ResetPasswordFlow.new(user)
+
+ flow.request_password_reset
+ flow.should_have_sent_reset_email
+ flow.reset_password "new-password"
+ flow.should_be_signed_in
+ flow.sign_out
+ flow.sign_in "wrong-password"
+ flow.should_have_password_error
+ flow.sign_in "new-password"
+ flow.should_be_signed_in
+ end
+end
diff --git a/spec/requests/api/me/show_spec.cr b/spec/requests/api/me/show_spec.cr
new file mode 100644
index 0000000..0e1f91f
--- /dev/null
+++ b/spec/requests/api/me/show_spec.cr
@@ -0,0 +1,17 @@
+require "../../../spec_helper"
+
+describe Api::Me::Show do
+ it "returns the signed in user" do
+ user = UserFactory.create
+
+ response = ApiClient.auth(user).exec(Api::Me::Show)
+
+ response.should send_json(200, email: user.email)
+ end
+
+ it "fails if not authenticated" do
+ response = ApiClient.exec(Api::Me::Show)
+
+ response.status_code.should eq(401)
+ end
+end
diff --git a/spec/requests/api/sign_ins/create_spec.cr b/spec/requests/api/sign_ins/create_spec.cr
new file mode 100644
index 0000000..520c2df
--- /dev/null
+++ b/spec/requests/api/sign_ins/create_spec.cr
@@ -0,0 +1,33 @@
+require "../../../spec_helper"
+
+describe Api::SignIns::Create do
+ it "returns a token" do
+ UserToken.stub_token("fake-token") do
+ user = UserFactory.create
+
+ response = ApiClient.exec(Api::SignIns::Create, user: valid_params(user))
+
+ response.should send_json(200, token: "fake-token")
+ end
+ end
+
+ it "returns an error if credentials are invalid" do
+ user = UserFactory.create
+ invalid_params = valid_params(user).merge(password: "incorrect")
+
+ response = ApiClient.exec(Api::SignIns::Create, user: invalid_params)
+
+ response.should send_json(
+ 400,
+ param: "password",
+ details: "password is wrong"
+ )
+ end
+end
+
+private def valid_params(user : User)
+ {
+ email: user.email,
+ password: "password",
+ }
+end
diff --git a/spec/requests/api/sign_ups/create_spec.cr b/spec/requests/api/sign_ups/create_spec.cr
new file mode 100644
index 0000000..2a23542
--- /dev/null
+++ b/spec/requests/api/sign_ups/create_spec.cr
@@ -0,0 +1,34 @@
+require "../../../spec_helper"
+
+describe Api::SignUps::Create do
+ it "creates user on sign up" do
+ UserToken.stub_token("fake-token") do
+ response = ApiClient.exec(Api::SignUps::Create, user: valid_params)
+
+ response.should send_json(200, token: "fake-token")
+ new_user = UserQuery.first
+ new_user.email.should eq(valid_params[:email])
+ end
+ end
+
+ it "returns error for invalid params" do
+ invalid_params = valid_params.merge(password_confirmation: "wrong")
+
+ response = ApiClient.exec(Api::SignUps::Create, user: invalid_params)
+
+ UserQuery.new.select_count.should eq(0)
+ response.should send_json(
+ 400,
+ param: "password_confirmation",
+ details: "password_confirmation must match"
+ )
+ end
+end
+
+private def valid_params
+ {
+ email: "test@email.com",
+ password: "password",
+ password_confirmation: "password",
+ }
+end
diff --git a/spec/setup/.keep b/spec/setup/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/spec/setup/clean_database.cr b/spec/setup/clean_database.cr
new file mode 100644
index 0000000..a1bc631
--- /dev/null
+++ b/spec/setup/clean_database.cr
@@ -0,0 +1,3 @@
+Spec.before_each do
+ AppDatabase.truncate
+end
diff --git a/spec/setup/configure_lucky_flow.cr b/spec/setup/configure_lucky_flow.cr
new file mode 100644
index 0000000..504a3d3
--- /dev/null
+++ b/spec/setup/configure_lucky_flow.cr
@@ -0,0 +1,37 @@
+# For more detailed documentation, visit
+# https://luckyframework.org/guides/testing/html-and-interactivity
+
+LuckyFlow.configure do |settings|
+ settings.stop_retrying_after = 200.milliseconds
+ settings.base_uri = Lucky::RouteHelper.settings.base_uri
+
+ # LuckyFlow will install the chromedriver for you located in
+ # ~./webdrivers/. Uncomment this to point to a specific driver
+ # settings.driver_path = "/path/to/specific/chromedriver"
+end
+
+# By default, LuckyFlow is set in "headless" mode (no browser window shown).
+# Uncomment this to enable running `LuckyFlow` in a Google Chrome window instead.
+# Be sure to disable for CI.
+#
+# LuckyFlow.default_driver = "chrome"
+
+# LuckyFlow uses a registry for each driver. By default, chrome, and headless_chrome
+# are available. If you'd like to register your own custom driver, you can register
+# it here.
+#
+# LuckyFlow::Registry.register :firefox do
+# # add your custom driver here
+# end
+
+# Setup specs to allow you to change the driver on the fly
+# per spec by setting a tag on specific specs. Requires the
+# driver to be registered through `LuckyFlow::Registry` first.
+#
+# ```
+# it "uses headless_chrome" do
+# end
+# it "uses webless", tags: "webless" do
+# end
+# ```
+LuckyFlow::Spec.setup
diff --git a/spec/setup/reset_emails.cr b/spec/setup/reset_emails.cr
new file mode 100644
index 0000000..140ab41
--- /dev/null
+++ b/spec/setup/reset_emails.cr
@@ -0,0 +1,3 @@
+Spec.before_each do
+ Carbon::DevAdapter.reset
+end
diff --git a/spec/setup/setup_database.cr b/spec/setup/setup_database.cr
new file mode 100644
index 0000000..393c6da
--- /dev/null
+++ b/spec/setup/setup_database.cr
@@ -0,0 +1,2 @@
+Db::Create.new(quiet: true).call
+Db::Migrate.new(quiet: true).call
diff --git a/spec/setup/start_app_server.cr b/spec/setup/start_app_server.cr
new file mode 100644
index 0000000..ff0bfee
--- /dev/null
+++ b/spec/setup/start_app_server.cr
@@ -0,0 +1,10 @@
+app_server = AppServer.new
+
+spawn do
+ app_server.listen
+end
+
+Spec.after_suite do
+ LuckyFlow.shutdown
+ app_server.close
+end
diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr
new file mode 100644
index 0000000..9391464
--- /dev/null
+++ b/spec/spec_helper.cr
@@ -0,0 +1,26 @@
+ENV["LUCKY_ENV"] = "test"
+ENV["DEV_PORT"] = "5001"
+require "spec"
+require "lucky_flow"
+require "lucky_flow/ext/lucky"
+require "lucky_flow/ext/avram"
+
+require "lucky_flow/ext/authentic"
+require "../src/app"
+require "./support/flows/base_flow"
+require "./support/**"
+require "../db/migrations/**"
+
+# Add/modify files in spec/setup to start/configure programs or run hooks
+#
+# By default there are scripts for setting up and cleaning the database,
+# configuring LuckyFlow, starting the app server, etc.
+require "./setup/**"
+
+include Carbon::Expectations
+include Lucky::RequestExpectations
+include LuckyFlow::Expectations
+
+Avram::Migrator::Runner.new.ensure_migrated!
+Avram::SchemaEnforcer.ensure_correct_column_mappings!
+Habitat.raise_if_missing_settings!
diff --git a/spec/support/.keep b/spec/support/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/spec/support/api_client.cr b/spec/support/api_client.cr
new file mode 100644
index 0000000..46d449a
--- /dev/null
+++ b/spec/support/api_client.cr
@@ -0,0 +1,12 @@
+class ApiClient < Lucky::BaseHTTPClient
+ app AppServer.new
+
+ def initialize
+ super
+ headers("Content-Type": "application/json")
+ end
+
+ def self.auth(user : User)
+ new.headers("Authorization": UserToken.generate(user))
+ end
+end
diff --git a/spec/support/factories/.keep b/spec/support/factories/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/spec/support/factories/user_factory.cr b/spec/support/factories/user_factory.cr
new file mode 100644
index 0000000..bb837ee
--- /dev/null
+++ b/spec/support/factories/user_factory.cr
@@ -0,0 +1,6 @@
+class UserFactory < Avram::Factory
+ def initialize
+ email "#{sequence("test-email")}@example.com"
+ encrypted_password Authentic.generate_encrypted_password("password")
+ end
+end
diff --git a/spec/support/flows/authentication_flow.cr b/spec/support/flows/authentication_flow.cr
new file mode 100644
index 0000000..183697f
--- /dev/null
+++ b/spec/support/flows/authentication_flow.cr
@@ -0,0 +1,45 @@
+class AuthenticationFlow < BaseFlow
+ private getter email
+
+ def initialize(@email : String)
+ end
+
+ def sign_up(password)
+ visit SignUps::New
+ fill_form SignUpUser,
+ email: email,
+ password: password,
+ password_confirmation: password
+ click "@sign-up-button"
+ end
+
+ def sign_out
+ visit Me::Show
+ sign_out_button.click
+ end
+
+ def sign_in(password)
+ visit SignIns::New
+ fill_form SignInUser,
+ email: email,
+ password: password
+ click "@sign-in-button"
+ end
+
+ def should_be_signed_in
+ current_page.should have_element("@sign-out-button")
+ end
+
+ def should_have_password_error
+ current_page.should have_element("body", text: "Password is wrong")
+ end
+
+ private def sign_out_button
+ el("@sign-out-button")
+ end
+
+ # NOTE: this is a shim for readability
+ private def current_page
+ self
+ end
+end
diff --git a/spec/support/flows/base_flow.cr b/spec/support/flows/base_flow.cr
new file mode 100644
index 0000000..93709b2
--- /dev/null
+++ b/spec/support/flows/base_flow.cr
@@ -0,0 +1,3 @@
+# Add methods that all or most Flows need to share
+class BaseFlow < LuckyFlow
+end
diff --git a/spec/support/flows/reset_password_flow.cr b/spec/support/flows/reset_password_flow.cr
new file mode 100644
index 0000000..b1df710
--- /dev/null
+++ b/spec/support/flows/reset_password_flow.cr
@@ -0,0 +1,42 @@
+class ResetPasswordFlow < BaseFlow
+ private getter user, authentication_flow
+ delegate sign_in, sign_out, should_have_password_error, should_be_signed_in,
+ to: authentication_flow
+ delegate email, to: user
+
+ def initialize(@user : User)
+ @authentication_flow = AuthenticationFlow.new(user.email)
+ end
+
+ def request_password_reset
+ with_fake_token do
+ visit PasswordResetRequests::New
+ fill_form RequestPasswordReset,
+ email: email
+ click "@request-password-reset-button"
+ end
+ end
+
+ def should_have_sent_reset_email
+ with_fake_token do
+ user = UserQuery.new.email(email).first
+ PasswordResetRequestEmail.new(user).should be_delivered
+ end
+ end
+
+ def reset_password(password)
+ user = UserQuery.new.email(email).first
+ token = Authentic.generate_password_reset_token(user)
+ visit PasswordResets::New.with(user.id, token)
+ fill_form ResetPassword,
+ password: password,
+ password_confirmation: password
+ click "@update-password-button"
+ end
+
+ private def with_fake_token(&)
+ PasswordResetRequestEmail.temp_config(stubbed_token: "fake") do
+ yield
+ end
+ end
+end
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
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
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
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 @@
+
Please reset your password
+
+Reset password
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
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
diff --git a/src/operations/mixins/.keep b/src/operations/mixins/.keep
new file mode 100644
index 0000000..e69de29
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
diff --git a/src/queries/mixins/.keep b/src/queries/mixins/.keep
new file mode 100644
index 0000000..e69de29
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
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
diff --git a/storage/app/.gitignore b/storage/app/.gitignore
deleted file mode 100644
index 8f4803c..0000000
--- a/storage/app/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-*
-!public/
-!.gitignore
diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore
deleted file mode 100644
index d6b7ef3..0000000
--- a/storage/app/public/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore
deleted file mode 100644
index 05c4471..0000000
--- a/storage/framework/.gitignore
+++ /dev/null
@@ -1,9 +0,0 @@
-compiled.php
-config.php
-down
-events.scanned.php
-maintenance.php
-routes.php
-routes.scanned.php
-schedule-*
-services.json
diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore
deleted file mode 100644
index 01e4a6c..0000000
--- a/storage/framework/cache/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-*
-!data/
-!.gitignore
diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore
deleted file mode 100644
index d6b7ef3..0000000
--- a/storage/framework/cache/data/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore
deleted file mode 100644
index d6b7ef3..0000000
--- a/storage/framework/sessions/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore
deleted file mode 100644
index d6b7ef3..0000000
--- a/storage/framework/testing/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore
deleted file mode 100644
index d6b7ef3..0000000
--- a/storage/framework/views/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore
deleted file mode 100644
index d6b7ef3..0000000
--- a/storage/logs/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/tasks.cr b/tasks.cr
new file mode 100644
index 0000000..5a892d4
--- /dev/null
+++ b/tasks.cr
@@ -0,0 +1,25 @@
+# This file loads your app and all your tasks when running 'lucky'
+#
+# Run 'lucky --help' to see all available tasks.
+#
+# Learn to create your own tasks:
+# https://luckyframework.org/guides/command-line-tasks/custom-tasks
+
+# See `LuckyEnv#task?`
+ENV["LUCKY_TASK"] = "true"
+
+# Load Lucky and the app (actions, models, etc.)
+require "./src/app"
+require "lucky_task"
+
+# You can add your own tasks here in the ./tasks folder
+require "./tasks/**"
+
+# Load migrations
+require "./db/migrations/**"
+
+# Load Lucky tasks (dev, routes, etc.)
+require "lucky/tasks/**"
+require "avram/lucky/tasks"
+
+LuckyTask::Runner.run
diff --git a/tasks/.keep b/tasks/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/tasks/db/seed/required_data.cr b/tasks/db/seed/required_data.cr
new file mode 100644
index 0000000..d866040
--- /dev/null
+++ b/tasks/db/seed/required_data.cr
@@ -0,0 +1,30 @@
+require "../../../spec/support/factories/**"
+
+# Add seeds here that are *required* for your app to work.
+# For example, you might need at least one admin user or you might need at least
+# one category for your blog posts for the app to work.
+#
+# Use `Db::Seed::SampleData` if your only want to add sample data helpful for
+# development.
+class Db::Seed::RequiredData < LuckyTask::Task
+ summary "Add database records required for the app to work"
+
+ def call
+ # Using a Avram::Factory:
+ #
+ # Use the defaults, but override just the email
+ # UserFactory.create &.email("me@example.com")
+
+ # Using a SaveOperation:
+ #
+ # SaveUser.create!(email: "me@example.com", name: "Jane")
+ #
+ # You likely want to be able to run this file more than once. To do that,
+ # only create the record if it doesn't exist yet:
+ #
+ # unless UserQuery.new.email("me@example.com").first?
+ # SaveUser.create!(email: "me@example.com", name: "Jane")
+ # end
+ puts "Done adding required data"
+ end
+end
diff --git a/tasks/db/seed/sample_data.cr b/tasks/db/seed/sample_data.cr
new file mode 100644
index 0000000..231d7e8
--- /dev/null
+++ b/tasks/db/seed/sample_data.cr
@@ -0,0 +1,30 @@
+require "../../../spec/support/factories/**"
+
+# Add sample data helpful for development, e.g. (fake users, blog posts, etc.)
+#
+# Use `Db::Seed::RequiredData` if you need to create data *required* for your
+# app to work.
+class Db::Seed::SampleData < LuckyTask::Task
+ summary "Add sample database records helpful for development"
+
+ def call
+ # Using an Avram::Factory:
+ #
+ # Use the defaults, but override just the email
+ # UserFactory.create &.email("me@example.com")
+
+ # Using a SaveOperation:
+ # ```
+ # SignUpUser.create!(email: "me@example.com", password: "test123", password_confirmation: "test123")
+ # ```
+ #
+ # You likely want to be able to run this file more than once. To do that,
+ # only create the record if it doesn't exist yet:
+ # ```
+ # if UserQuery.new.email("me@example.com").none?
+ # SignUpUser.create!(email: "me@example.com", password: "test123", password_confirmation: "test123")
+ # end
+ # ```
+ puts "Done adding sample data"
+ end
+end
diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php
deleted file mode 100644
index cc68301..0000000
--- a/tests/CreatesApplication.php
+++ /dev/null
@@ -1,21 +0,0 @@
-make(Kernel::class)->bootstrap();
-
- return $app;
- }
-}
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
deleted file mode 100644
index 8364a84..0000000
--- a/tests/Feature/ExampleTest.php
+++ /dev/null
@@ -1,19 +0,0 @@
-get('/');
-
- $response->assertStatus(200);
- }
-}
diff --git a/tests/TestCase.php b/tests/TestCase.php
deleted file mode 100644
index 2932d4a..0000000
--- a/tests/TestCase.php
+++ /dev/null
@@ -1,10 +0,0 @@
-assertTrue(true);
- }
-}
diff --git a/vite.config.js b/vite.config.js
deleted file mode 100644
index 421b569..0000000
--- a/vite.config.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { defineConfig } from 'vite';
-import laravel from 'laravel-vite-plugin';
-
-export default defineConfig({
- plugins: [
- laravel({
- input: ['resources/css/app.css', 'resources/js/app.js'],
- refresh: true,
- }),
- ],
-});
diff --git a/webpack.mix.js b/webpack.mix.js
new file mode 100644
index 0000000..0c3ecc9
--- /dev/null
+++ b/webpack.mix.js
@@ -0,0 +1,113 @@
+/*
+ | Mix Asset Management
+ |
+ | Mix provides a clean, fluent API for defining some Webpack build steps
+ | for your application.
+ |
+ | Docs: https://github.com/JeffreyWay/laravel-mix/tree/master/docs#readme
+ */
+
+let mix = require("laravel-mix");
+let plugins = [];
+
+// Customize the notifier to be less noisy
+let WebpackNotifierPlugin = require('webpack-notifier');
+let webpackNotifier = new WebpackNotifierPlugin({
+ alwaysNotify: false,
+ skipFirstNotification: true
+})
+plugins.push(webpackNotifier)
+
+// Compress static assets in production
+if (mix.inProduction()) {
+ let CompressionWepackPlugin = require('compression-webpack-plugin');
+ let gzipCompression = new CompressionWepackPlugin({
+ compressionOptions: { level: 9 },
+ test: /\.js$|\.css$|\.html$|\.svg$/
+ })
+ plugins.push(gzipCompression)
+
+ // Add additional compression plugins here.
+ // For example if you want to add Brotli compression:
+ //
+ // let brotliCompression = new CompressionWepackPlugin({
+ // compressionOptions: { level: 11 },
+ // filename: '[path].br[query]',
+ // algorithm: 'brotliCompress',
+ // test: /\.js$|\.css$|\.html$|\.svg$/
+ // })
+ // plugins.push(brotliCompression)
+}
+
+mix
+ // Set public path so manifest gets output here
+ .setPublicPath("public")
+ // JS entry file. Supports Vue, and uses Babel
+ //
+ // More info and options (like React support) here:
+ // https://github.com/JeffreyWay/laravel-mix/blob/master/docs/mixjs.md
+ .js("src/js/app.js", "js")
+ // SASS entry file. Uses autoprefixer automatically.
+ .sass("src/css/app.scss", "css")
+ // Customize postCSS:
+ // https://github.com/JeffreyWay/laravel-mix/blob/master/docs/css-preprocessors.md#postcss-plugins
+ .options({
+ // If you want to process images, change this to true and add options from
+ // https://github.com/tcoopman/image-webpack-loader
+ imgLoaderOptions: { enabled: false },
+ // Stops Mix from clearing the console when compilation succeeds
+ clearConsole: false
+ })
+ // Add assets to the manifest
+ .version(["public/assets"])
+ // Reduce noise in Webpack output
+ .webpackConfig({
+ stats: "errors-only",
+ plugins: plugins,
+ watchOptions: {
+ ignored: /node_modules/
+ }
+ })
+ // Disable default Mix notifications because we're using our own notifier
+ .disableNotifications()
+
+// Full API
+// Docs: https://github.com/JeffreyWay/laravel-mix/tree/master/docs#readme
+//
+// mix.js(src, output);
+// mix.react(src, output); <-- Identical to mix.js(), but registers React Babel compilation.
+// mix.preact(src, output); <-- Identical to mix.js(), but registers Preact compilation.
+// mix.coffee(src, output); <-- Identical to mix.js(), but registers CoffeeScript compilation.
+// mix.ts(src, output); <-- TypeScript support. Requires tsconfig.json to exist in the same folder as webpack.mix.js
+// mix.extract(vendorLibs);
+// mix.sass(src, output);
+// mix.less(src, output);
+// mix.stylus(src, output);
+// mix.postCss(src, output, [require('postcss-some-plugin')()]);
+// mix.browserSync('my-site.test');
+// mix.combine(files, destination);
+// mix.babel(files, destination); <-- Identical to mix.combine(), but also includes Babel compilation.
+// mix.copy(from, to);
+// mix.copyDirectory(fromDir, toDir);
+// mix.minify(file);
+// mix.sourceMaps(); // Enable sourcemaps
+// mix.version(); // Enable versioning.
+// mix.disableNotifications();
+// mix.setPublicPath('path/to/public');
+// mix.setResourceRoot('prefix/for/resource/locators');
+// mix.autoload({}); <-- Will be passed to Webpack's ProvidePlugin.
+// mix.webpackConfig({}); <-- Override webpack.config.js, without editing the file directly.
+// mix.babelConfig({}); <-- Merge extra Babel configuration (plugins, etc.) with Mix's default.
+// mix.then(function () {}) <-- Will be triggered each time Webpack finishes building.
+// mix.when(condition, function (mix) {}) <-- Call function if condition is true.
+// mix.override(function (webpackConfig) {}) <-- Will be triggered once the webpack config object has been fully generated by Mix.
+// mix.dump(); <-- Dump the generated webpack config object to the console.
+// mix.extend(name, handler) <-- Extend Mix's API with your own components.
+// mix.options({
+// extractVueStyles: false, // Extract .vue component styling to file, rather than inline.
+// globalVueStyles: file, // Variables file to be imported in every component.
+// processCssUrls: true, // Process/optimize relative stylesheet url()'s. Set to false, if you don't want them touched.
+// purifyCss: false, // Remove unused CSS selectors.
+// terser: {}, // Terser-specific options. https://github.com/webpack-contrib/terser-webpack-plugin#options
+// postCss: [] // Post-CSS options: https://github.com/postcss/postcss/blob/master/docs/plugins.md
+// });