Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f478010699 | ||
|
|
77bdebd0b1 | ||
|
|
a9f4b05b16 | ||
|
|
2024a8367b | ||
|
|
0cd5093976 | ||
|
|
4f3d003a35 | ||
|
|
9f00a34f7d | ||
|
|
15e6c8dff8 | ||
|
|
3d679586d8 | ||
|
|
10bcb49b56 | ||
|
|
57462f98fc | ||
|
|
c05c3d3a89 | ||
|
|
5a91e0d0dd |
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,2 +1,4 @@
|
|||
/.rake_tasks~
|
||||
/temp.db
|
||||
/vendor/
|
||||
/bin/
|
||||
2
Gemfile
2
Gemfile
|
|
@ -6,8 +6,6 @@ gem 'tilt'
|
|||
gem 'erubi'
|
||||
gem 'sequel'
|
||||
gem 'rake'
|
||||
gem 'rodauth'
|
||||
gem 'bcrypt'
|
||||
|
||||
# Change to whatever database you plan to use
|
||||
gem 'sqlite3'
|
||||
|
|
|
|||
59
README.md
59
README.md
|
|
@ -14,6 +14,17 @@ Next Roda app template iterations will add database, then authentication.
|
|||
|
||||
## Setup
|
||||
|
||||
### Get git
|
||||
|
||||
```
|
||||
sudo apt install git
|
||||
git config --global user.email "myemail@gmail.com"
|
||||
git config --global user.name "Full Name"
|
||||
git config --global credential.helper "cache"
|
||||
|
||||
git clone https://path/to/project.git
|
||||
```
|
||||
|
||||
### Prereq installs
|
||||
|
||||
Will need ruby; install it via package manager or a ruby manager like rbenv/ruby-build. Will need the roda gem, and then an application server such as puma (recommended), gunicorn, or passenger. My examples will use system ruby and puma.
|
||||
|
|
@ -23,32 +34,64 @@ Will need ruby; install it via package manager or a ruby manager like rbenv/ruby
|
|||
With this example, will basically just ignore the project's Gemfile. Debian 12 has a pretty current ruby version so just using it.
|
||||
|
||||
```
|
||||
sudo apt install ruby ruby-rack puma ruby-erubi ruby-tilt ruby-sequel ruby-sqlite3 rake ruby-bcrypt
|
||||
sudo gem install roda rack-unreloader rodauth --no-document
|
||||
sudo apt install ruby ruby-rack puma ruby-erubi ruby-tilt ruby-sequel ruby-sqlite3 rake
|
||||
sudo gem install roda rack-unreloader
|
||||
|
||||
cd my-project
|
||||
```
|
||||
|
||||
#### Option 2: Bundler
|
||||
#### Option 2a: Bundler system package
|
||||
|
||||
Run the bundle command from the project's root directory
|
||||
Run the bundle install command from the project's root directory
|
||||
|
||||
```
|
||||
sudo apt install ruby ruby-bundler
|
||||
cd /path/to/project/
|
||||
bundle config set --local path 'vendor/bundle'
|
||||
sudo apt install ruby ruby-bundler ruby-dev gcc pkgconf make g++ libyaml-dev libffi-dev
|
||||
echo 'gem: --no-document' >> ~/.gemrc
|
||||
sudo cp ~/.gemrc /root/
|
||||
bundle config set --global path 'vendor/bundle'
|
||||
sudo cp -r .bundle /root/
|
||||
|
||||
cd my-project
|
||||
bundle install
|
||||
```
|
||||
|
||||
#### Option 2b: Bundler system gem (recommended)
|
||||
|
||||
Similar to above but might as well use the gem install to get the latest bundler. The Debian apt packaged bundler is currently a bit outdated and missing some features compared to the latest.
|
||||
|
||||
```
|
||||
sudo apt install ruby ruby-dev gcc pkgconf make g++ libyaml-dev # zlib1g-dev libffi-dev #(for rails stuff)
|
||||
echo 'gem: --no-document' >> ~/.gemrc
|
||||
sudo cp ~/.gemrc /root/
|
||||
sudo gem install bundler
|
||||
bundle config set --global path 'vendor/bundle'
|
||||
sudo cp -r .bundle /root/
|
||||
|
||||
cd my-project
|
||||
bundle install
|
||||
```
|
||||
|
||||
#### Option 3: Rbenv Ruby
|
||||
|
||||
Todo
|
||||
|
||||
## Run it
|
||||
|
||||
In the project root directory:
|
||||
|
||||
```
|
||||
bundle exec puma
|
||||
|
||||
# or if you did not use bundler to install puma...
|
||||
puma
|
||||
```
|
||||
|
||||
This default to development mode. Run it in production mode with:
|
||||
|
||||
```
|
||||
RACK_ENV=production bundle exec puma
|
||||
|
||||
# or if you did not install puma
|
||||
RACK_ENV=production puma
|
||||
```
|
||||
|
||||
|
|
@ -56,7 +99,7 @@ For development, just run it like that. For production, probably want to set up
|
|||
|
||||
### Run it with systemd in production
|
||||
|
||||
Copy the example myapp.service file to `/etc/systemd/system/` and edit accordingly. The example assumes a user named "myapp" with a group name "myapp", the application files are in `/opt/myapp/`, and puma is the system puma.
|
||||
Copy the example myapp.service file to `/etc/systemd/system/` and edit accordingly. The example assumes a user named "myapp" with a group name "myapp", the application files are in `/opt/myapp/`, and puma is the system puma. If you installed puma with bundler, the exe will be at `/opt/myapp/vendor/bundle/ruby/3.1.0/bin/puma`.
|
||||
|
||||
### Notes
|
||||
|
||||
|
|
|
|||
12
app.rb
12
app.rb
|
|
@ -7,19 +7,8 @@ class App < Roda
|
|||
plugin :render, escape: true
|
||||
plugin :route_csrf
|
||||
|
||||
#secret = ENV['SESSION_SECRET']
|
||||
secret = 'hgfde456789ijhgt67uhgfdswertgbvfghjhgfde456789ijhgt67uhgfdswertgbvfghj'
|
||||
plugin :sessions, secret: secret
|
||||
plugin :rodauth do
|
||||
enable :login, :logout, :create_account
|
||||
require_email_address_logins? false
|
||||
require_login_confirmation? false
|
||||
hmac_secret secret
|
||||
end
|
||||
|
||||
route do |r|
|
||||
check_csrf!
|
||||
r.rodauth
|
||||
|
||||
r.root do
|
||||
view :index
|
||||
|
|
@ -31,7 +20,6 @@ class App < Roda
|
|||
end
|
||||
|
||||
r.on 'hello' do
|
||||
rodauth.require_authentication
|
||||
r.is String do |name|
|
||||
@page_title = 'A Custom Greeting'
|
||||
@name = name.capitalize
|
||||
|
|
|
|||
|
|
@ -1,240 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Sequel.migration do
|
||||
up do
|
||||
extension :date_arithmetic
|
||||
|
||||
# Used by the account verification and close account features
|
||||
create_table(:account_statuses) do
|
||||
Integer :id, primary_key: true
|
||||
String :name, null: false, unique: true
|
||||
end
|
||||
from(:account_statuses).import([:id, :name], [[1, 'Unverified'], [2, 'Verified'], [3, 'Closed']])
|
||||
|
||||
db = self
|
||||
create_table(:accounts) do
|
||||
primary_key :id, type: :Bignum
|
||||
foreign_key :status_id, :account_statuses, null: false, default: 1
|
||||
if db.database_type == :postgres
|
||||
citext :email, null: false
|
||||
constraint :valid_email, email: /^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+$/
|
||||
else
|
||||
String :email, null: false
|
||||
end
|
||||
if db.supports_partial_indexes?
|
||||
index :email, unique: true, where: {status_id: [1, 2]}
|
||||
else
|
||||
index :email, unique: true
|
||||
end
|
||||
end
|
||||
|
||||
deadline_opts = proc do |days|
|
||||
if database_type == :mysql
|
||||
{null: false}
|
||||
else
|
||||
{null: false, default: Sequel.date_add(Sequel::CURRENT_TIMESTAMP, days: days)}
|
||||
end
|
||||
end
|
||||
|
||||
# Used by the audit logging feature
|
||||
json_type = case database_type
|
||||
when :postgres
|
||||
:jsonb
|
||||
when :sqlite, :mysql
|
||||
:json
|
||||
else
|
||||
String
|
||||
end
|
||||
create_table(:account_authentication_audit_logs) do
|
||||
primary_key :id, type: :Bignum
|
||||
foreign_key :account_id, :accounts, null: false, type: :Bignum
|
||||
DateTime :at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
String :message, null: false
|
||||
column :metadata, json_type
|
||||
index [:account_id, :at], name: :audit_account_at_idx
|
||||
index :at, name: :audit_at_idx
|
||||
end
|
||||
|
||||
# Used by the password reset feature
|
||||
create_table(:account_password_reset_keys) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :key, null: false
|
||||
DateTime :deadline, deadline_opts[1]
|
||||
DateTime :email_last_sent, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
end
|
||||
|
||||
# Used by the jwt refresh feature
|
||||
create_table(:account_jwt_refresh_keys) do
|
||||
primary_key :id, type: :Bignum
|
||||
foreign_key :account_id, :accounts, null: false, type: :Bignum
|
||||
String :key, null: false
|
||||
DateTime :deadline, deadline_opts[1]
|
||||
index :account_id, name: :account_jwt_rk_account_id_idx
|
||||
end
|
||||
|
||||
# Used by the account verification feature
|
||||
create_table(:account_verification_keys) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :key, null: false
|
||||
DateTime :requested_at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
DateTime :email_last_sent, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
end
|
||||
|
||||
# Used by the verify login change feature
|
||||
create_table(:account_login_change_keys) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :key, null: false
|
||||
String :login, null: false
|
||||
DateTime :deadline, deadline_opts[1]
|
||||
end
|
||||
|
||||
# Used by the remember me feature
|
||||
create_table(:account_remember_keys) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :key, null: false
|
||||
DateTime :deadline, deadline_opts[14]
|
||||
end
|
||||
|
||||
# Used by the lockout feature
|
||||
create_table(:account_login_failures) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
Integer :number, null: false, default: 1
|
||||
end
|
||||
create_table(:account_lockouts) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :key, null: false
|
||||
DateTime :deadline, deadline_opts[1]
|
||||
DateTime :email_last_sent
|
||||
end
|
||||
|
||||
# Used by the email auth feature
|
||||
create_table(:account_email_auth_keys) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :key, null: false
|
||||
DateTime :deadline, deadline_opts[1]
|
||||
DateTime :email_last_sent, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
end
|
||||
|
||||
# Used by the password expiration feature
|
||||
create_table(:account_password_change_times) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
DateTime :changed_at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
end
|
||||
|
||||
# Used by the account expiration feature
|
||||
create_table(:account_activity_times) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
DateTime :last_activity_at, null: false
|
||||
DateTime :last_login_at, null: false
|
||||
DateTime :expired_at
|
||||
end
|
||||
|
||||
# Used by the single session feature
|
||||
create_table(:account_session_keys) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :key, null: false
|
||||
end
|
||||
|
||||
# Used by the active sessions feature
|
||||
create_table(:account_active_session_keys) do
|
||||
foreign_key :account_id, :accounts, type: :Bignum
|
||||
String :session_id
|
||||
Time :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
Time :last_use, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
primary_key [:account_id, :session_id]
|
||||
end
|
||||
|
||||
# Used by the webauthn feature
|
||||
create_table(:account_webauthn_user_ids) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :webauthn_id, null: false
|
||||
end
|
||||
create_table(:account_webauthn_keys) do
|
||||
foreign_key :account_id, :accounts, type: :Bignum
|
||||
String :webauthn_id
|
||||
String :public_key, null: false
|
||||
Integer :sign_count, null: false
|
||||
Time :last_use, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
primary_key [:account_id, :webauthn_id]
|
||||
end
|
||||
|
||||
# Used by the otp feature
|
||||
create_table(:account_otp_keys) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :key, null: false
|
||||
Integer :num_failures, null: false, default: 0
|
||||
Time :last_use, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
end
|
||||
|
||||
# Used by the recovery codes feature
|
||||
create_table(:account_recovery_codes) do
|
||||
foreign_key :id, :accounts, type: :Bignum
|
||||
String :code
|
||||
primary_key [:id, :code]
|
||||
end
|
||||
|
||||
# Used by the sms codes feature
|
||||
create_table(:account_sms_codes) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :phone_number, null: false
|
||||
Integer :num_failures
|
||||
String :code
|
||||
DateTime :code_issued_at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||
end
|
||||
|
||||
case database_type
|
||||
when :postgres
|
||||
user = get(Sequel.lit('current_user')) + '_password'
|
||||
run "GRANT REFERENCES ON accounts TO #{user}"
|
||||
when :mysql, :mssql
|
||||
user = if database_type == :mysql
|
||||
get(Sequel.lit('current_user')).sub(/_password@/, '@')
|
||||
else
|
||||
get(Sequel.function(:DB_NAME))
|
||||
end
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_statuses TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON accounts TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_authentication_audit_logs TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_password_reset_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_jwt_refresh_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_verification_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_login_change_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_remember_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_login_failures TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_email_auth_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_lockouts TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_password_change_times TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_activity_times TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_session_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_active_session_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_webauthn_user_ids TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_webauthn_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_otp_keys TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_recovery_codes TO #{user}"
|
||||
run "GRANT SELECT, INSERT, UPDATE, DELETE ON account_sms_codes TO #{user}"
|
||||
end
|
||||
end
|
||||
|
||||
down do
|
||||
drop_table(:account_sms_codes,
|
||||
:account_recovery_codes,
|
||||
:account_otp_keys,
|
||||
:account_webauthn_keys,
|
||||
:account_webauthn_user_ids,
|
||||
:account_session_keys,
|
||||
:account_active_session_keys,
|
||||
:account_activity_times,
|
||||
:account_password_change_times,
|
||||
:account_email_auth_keys,
|
||||
:account_lockouts,
|
||||
:account_login_failures,
|
||||
:account_remember_keys,
|
||||
:account_login_change_keys,
|
||||
:account_verification_keys,
|
||||
:account_jwt_refresh_keys,
|
||||
:account_password_reset_keys,
|
||||
:account_authentication_audit_logs,
|
||||
:accounts,
|
||||
:account_statuses)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rodauth/migrations'
|
||||
|
||||
Sequel.migration do
|
||||
up do
|
||||
create_table(:account_password_hashes) do
|
||||
foreign_key :id, :accounts, primary_key: true, type: :Bignum
|
||||
String :password_hash, null: false
|
||||
end
|
||||
Rodauth.create_database_authentication_functions(self)
|
||||
case database_type
|
||||
when :postgres
|
||||
user = get(Sequel.lit('current_user')).sub(/_password\z/, '')
|
||||
run "REVOKE ALL ON account_password_hashes FROM public"
|
||||
run "REVOKE ALL ON FUNCTION rodauth_get_salt(int8) FROM public"
|
||||
run "REVOKE ALL ON FUNCTION rodauth_valid_password_hash(int8, text) FROM public"
|
||||
run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}"
|
||||
run "GRANT SELECT(id) ON account_password_hashes TO #{user}"
|
||||
run "GRANT EXECUTE ON FUNCTION rodauth_get_salt(int8) TO #{user}"
|
||||
run "GRANT EXECUTE ON FUNCTION rodauth_valid_password_hash(int8, text) TO #{user}"
|
||||
when :mysql
|
||||
user = get(Sequel.lit('current_user')).sub(/_password@/, '@')
|
||||
db_name = get(Sequel.function(:database))
|
||||
run "GRANT EXECUTE ON #{db_name}.* TO #{user}"
|
||||
run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}"
|
||||
run "GRANT SELECT (id) ON account_password_hashes TO #{user}"
|
||||
when :mssql
|
||||
user = get(Sequel.function(:DB_NAME))
|
||||
run "GRANT EXECUTE ON rodauth_get_salt TO #{user}"
|
||||
run "GRANT EXECUTE ON rodauth_valid_password_hash TO #{user}"
|
||||
run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}"
|
||||
run "GRANT SELECT ON account_password_hashes(id) TO #{user}"
|
||||
end
|
||||
|
||||
# Used by the disallow_password_reuse feature
|
||||
create_table(:account_previous_password_hashes) do
|
||||
primary_key :id, type: :Bignum
|
||||
foreign_key :account_id, :accounts, type: :Bignum
|
||||
String :password_hash, null: false
|
||||
end
|
||||
Rodauth.create_database_previous_password_check_functions(self)
|
||||
|
||||
case database_type
|
||||
when :postgres
|
||||
user = get(Sequel.lit('current_user')).sub(/_password\z/, '')
|
||||
run "REVOKE ALL ON account_previous_password_hashes FROM public"
|
||||
run "REVOKE ALL ON FUNCTION rodauth_get_previous_salt(int8) FROM public"
|
||||
run "REVOKE ALL ON FUNCTION rodauth_previous_password_hash_match(int8, text) FROM public"
|
||||
run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}"
|
||||
run "GRANT SELECT(id, account_id) ON account_previous_password_hashes TO #{user}"
|
||||
run "GRANT USAGE ON account_previous_password_hashes_id_seq TO #{user}"
|
||||
run "GRANT EXECUTE ON FUNCTION rodauth_get_previous_salt(int8) TO #{user}"
|
||||
run "GRANT EXECUTE ON FUNCTION rodauth_previous_password_hash_match(int8, text) TO #{user}"
|
||||
when :mysql
|
||||
user = get(Sequel.lit('current_user')).sub(/_password@/, '@')
|
||||
db_name = get(Sequel.function(:database))
|
||||
run "GRANT EXECUTE ON #{db_name}.* TO #{user}"
|
||||
run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}"
|
||||
run "GRANT SELECT (id, account_id) ON account_previous_password_hashes TO #{user}"
|
||||
when :mssql
|
||||
user = get(Sequel.function(:DB_NAME))
|
||||
run "GRANT EXECUTE ON rodauth_get_previous_salt TO #{user}"
|
||||
run "GRANT EXECUTE ON rodauth_previous_password_hash_match TO #{user}"
|
||||
run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}"
|
||||
run "GRANT SELECT ON account_previous_password_hashes(id, account_id) TO #{user}"
|
||||
end
|
||||
end
|
||||
|
||||
down do
|
||||
Rodauth.drop_database_previous_password_check_functions(self)
|
||||
Rodauth.drop_database_authentication_functions(self)
|
||||
drop_table(:account_previous_password_hashes, :account_password_hashes)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
<p class="subtitle is-3">
|
||||
<p class="subtitle">
|
||||
Hello, <%= @name %>!
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
<p class="subtitle is-3">
|
||||
<p class="subtitle">
|
||||
Welcome to my new page! Running in <%= ENV['RACK_ENV'] %> mode!
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= @page_title || "My Website" %></title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title is-1"><%= @page_title || 'Page Title Placeholder' %></h1>
|
||||
<% # maybe put a flash section here some day %>
|
||||
<h1 class="title">
|
||||
<%= @page_title || 'Page Title Placeholder' %>
|
||||
</h1>
|
||||
<%== yield %>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<% for u in @users do %>
|
||||
<p class="subtitle is-3">
|
||||
<p class="subtitle">
|
||||
Hello, <%= u.name %>! You're #<%= u.id %>!
|
||||
</p>
|
||||
<% end %>
|
||||
|
|
|
|||
Loading…
Reference in a new issue