aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore17
-rw-r--r--.gitmodules3
-rw-r--r--.rspec2
-rw-r--r--.rubocop.yml11
-rw-r--r--Gemfile17
-rw-r--r--Gemfile.lock81
-rw-r--r--README.org189
-rw-r--r--Rakefile7
-rw-r--r--assets/biberon.svg1217
m---------assets/centigram0
-rw-r--r--assets/style.sass88
-rw-r--r--db/migrations/202210072143_add_users.rb49
-rw-r--r--lib/biberon.rb184
-rw-r--r--lib/biberon/config.rb71
-rw-r--r--lib/biberon/helpers/emails.rb65
-rw-r--r--lib/biberon/helpers/passwords.rb55
-rw-r--r--lib/biberon/models.rb3
-rw-r--r--lib/biberon/models/baby.rb16
-rw-r--r--lib/biberon/models/biberon.rb5
-rw-r--r--lib/biberon/models/user.rb44
-rw-r--r--lib/biberon/routes/babies.rb49
-rw-r--r--lib/biberon/routes/biberons.rb43
-rw-r--r--lib/biberon/routes/password.rb40
-rw-r--r--lib/biberon/routes/settings.rb48
-rw-r--r--lib/biberon/routes/users.rb31
-rw-r--r--lib/tasks/admin.rake20
-rw-r--r--lib/tasks/assets.rake10
-rw-r--r--lib/tasks/server.rake45
-rw-r--r--locales/en.yml65
-rw-r--r--locales/fr.yml69
-rw-r--r--package-lock.json408
-rw-r--r--package.json11
-rw-r--r--public/css/style.css38
-rw-r--r--public/img/favicon.pngbin0 -> 6193 bytes
-rw-r--r--public/img/favicon_180.pngbin0 -> 10500 bytes
-rw-r--r--public/img/favicon_192.pngbin0 -> 9736 bytes
-rw-r--r--public/img/undraw_baby_feeding.svg1
-rw-r--r--public/js/edit_baby.js38
-rw-r--r--spec/babies_spec.rb86
-rw-r--r--spec/biberons_spec.rb83
-rw-r--r--spec/requirements.rb46
-rw-r--r--spec/users_spec.rb58
-rw-r--r--views/babies/baby.slim (renamed from views/baby.slim)36
-rw-r--r--views/babies/graph.slim (renamed from views/graph.slim)2
-rw-r--r--views/babies/index.slim17
-rw-r--r--views/index.slim59
-rw-r--r--views/layout.slim35
-rw-r--r--views/password/change.slim24
-rw-r--r--views/password/new.slim16
-rw-r--r--views/settings.slim2
50 files changed, 3137 insertions, 367 deletions
diff --git a/.gitignore b/.gitignore
index 46c1ac6..3d25834 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,14 +1,13 @@
.env
+config.yml
.ruby-version
-db/biberon.db
-node_modules
-public/css/*.css
-!public/css/style.css
-public/css/*.css.br
-public/css/*.css.gz
-public/js/*.js
-!public/js/plot.js
+db/*.db
+coverage/
+node_modules/
+public/css/
+public/js/*.min.js
public/js/*.js.br
public/js/*.js.gz
+public/img/*.br
+public/img/*.gz
tmp/puma.pid
-tmp/app.log
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..12f36da
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "assets/centigram"]
+ path = assets/centigram
+ url = git@github.com:milouse/centigram.git
diff --git a/.rspec b/.rspec
new file mode 100644
index 0000000..73d7afd
--- /dev/null
+++ b/.rspec
@@ -0,0 +1,2 @@
+-I ./lib
+-r ./spec/requirements.rb
diff --git a/.rubocop.yml b/.rubocop.yml
index f491f50..6e2f97e 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -6,7 +6,6 @@ AllCops:
require:
- rubocop-performance
- rubocop-rake
-- rubocop-rspec
- rubocop-sequel
Layout:
@@ -29,6 +28,7 @@ Metrics/AbcSize:
Metrics/BlockLength:
Max: 30
Exclude:
+ - spec/**/*
- lib/tasks/*
- lib/biberon.rb
@@ -48,15 +48,6 @@ Metrics/PerceivedComplexity:
Metrics/CyclomaticComplexity:
Max: 10
-RSpec/ExampleLength:
- Enabled: no
-
-RSpec/MultipleExpectations:
- Enabled: no
-
-RSpec/NestedGroups:
- Max: 4
-
Style/Alias:
EnforcedStyle: prefer_alias_method
diff --git a/Gemfile b/Gemfile
index d634434..317d2ab 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,25 +4,28 @@ source 'https://rubygems.org'
ruby '>= 3.0'
gem 'bcrypt', '~> 3.1'
+gem 'mail', '~> 2.7'
+gem 'net-smtp', '~> 0.3' if RUBY_VERSION > '3.1.0'
gem 'puma', '~> 5.6'
gem 'r18n-core', '~> 5.0'
+gem 'rackup', '~> 0.2'
gem 'rainbow', '~> 3.1'
gem 'rake', '~> 13.0'
-gem 'roda', '~> 3.56'
-gem 'sequel', '~> 5.56'
+gem 'roda', '~> 3.61'
+gem 'ruby-mysql', '~> 3.0'
+gem 'sequel', '~> 5.61'
gem 'slim', '~> 4.1'
-gem 'sqlite3', '~> 1.4'
+gem 'sqlite3', '~> 1.5'
group :development do
- gem 'flay', '~> 2.13'
gem 'pry', '~> 0.14'
gem 'pry-doc', '~> 1.3'
gem 'reek', '~> 6.1'
gem 'rspec', '~> 3.11'
- gem 'rubocop', '~> 1.29'
- gem 'rubocop-performance', '~> 1.13'
+ gem 'rubocop', '~> 1.36'
+ gem 'rubocop-performance', '~> 1.15'
gem 'rubocop-rake', '~> 0.6'
- gem 'rubocop-rspec', '~> 2.11'
+ gem 'rubocop-rspec', '~> 2.13'
gem 'rubocop-sequel', '~> 0.3'
gem 'simplecov', '~> 0.21'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 96b2392..4658ef9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -6,38 +6,42 @@ GEM
coderay (1.1.3)
diff-lcs (1.5.0)
docile (1.4.0)
- erubi (1.10.0)
- flay (2.13.0)
- erubi (~> 1.10)
- path_expander (~> 1.0)
- ruby_parser (~> 3.0)
- sexp_processor (~> 4.0)
+ json (2.6.2)
kwalify (0.7.2)
+ mail (2.7.1)
+ mini_mime (>= 0.1.1)
method_source (1.0.0)
+ mini_mime (1.1.2)
+ net-protocol (0.1.3)
+ timeout
+ net-smtp (0.3.2)
+ net-protocol
nio4r (2.5.8)
parallel (1.22.1)
- parser (3.1.2.0)
+ parser (3.1.2.1)
ast (~> 2.4.1)
- path_expander (1.1.0)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
pry-doc (1.3.0)
pry (~> 0.11)
yard (~> 0.9.11)
- puma (5.6.4)
+ puma (5.6.5)
nio4r (~> 2.0)
r18n-core (5.0.1)
- rack (2.2.3)
+ rack (3.0.0)
+ rackup (0.2.2)
+ rack (>= 3.0.0.beta1)
+ webrick
rainbow (3.1.1)
rake (13.0.6)
reek (6.1.1)
kwalify (~> 0.7.0)
parser (~> 3.1.0)
rainbow (>= 2.0, < 4.0)
- regexp_parser (2.4.0)
+ regexp_parser (2.6.0)
rexml (3.2.5)
- roda (3.56.0)
+ roda (3.61.0)
rack
rspec (3.11.0)
rspec-core (~> 3.11.0)
@@ -45,38 +49,37 @@ GEM
rspec-mocks (~> 3.11.0)
rspec-core (3.11.0)
rspec-support (~> 3.11.0)
- rspec-expectations (3.11.0)
+ rspec-expectations (3.11.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
rspec-mocks (3.11.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
- rspec-support (3.11.0)
- rubocop (1.29.1)
+ rspec-support (3.11.1)
+ rubocop (1.36.0)
+ json (~> 2.3)
parallel (~> 1.10)
- parser (>= 3.1.0.0)
+ parser (>= 3.1.2.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
- rubocop-ast (>= 1.17.0, < 2.0)
+ rubocop-ast (>= 1.20.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
- rubocop-ast (1.18.0)
+ rubocop-ast (1.22.0)
parser (>= 3.1.1.0)
- rubocop-performance (1.13.3)
+ rubocop-performance (1.15.0)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rake (0.6.0)
rubocop (~> 1.0)
- rubocop-rspec (2.11.1)
- rubocop (~> 1.19)
- rubocop-sequel (0.3.3)
+ rubocop-rspec (2.13.2)
+ rubocop (~> 1.33)
+ rubocop-sequel (0.3.4)
rubocop (~> 1.0)
+ ruby-mysql (3.0.1)
ruby-progressbar (1.11.0)
- ruby_parser (3.19.1)
- sexp_processor (~> 4.16)
- sequel (5.56.0)
- sexp_processor (4.16.1)
+ sequel (5.61.0)
simplecov (0.21.2)
docile (~> 1.1)
simplecov-html (~> 0.11)
@@ -86,12 +89,13 @@ GEM
slim (4.1.0)
temple (>= 0.7.6, < 0.9)
tilt (>= 2.0.6, < 2.1)
- sqlite3 (1.4.2)
+ sqlite3 (1.5.3-x86_64-linux)
temple (0.8.2)
- tilt (2.0.10)
- unicode-display_width (2.1.0)
+ tilt (2.0.11)
+ timeout (0.3.0)
+ unicode-display_width (2.3.0)
webrick (1.7.0)
- yard (0.9.27)
+ yard (0.9.28)
webrick (~> 1.7.0)
PLATFORMS
@@ -99,25 +103,28 @@ PLATFORMS
DEPENDENCIES
bcrypt (~> 3.1)
- flay (~> 2.13)
+ mail (~> 2.7)
+ net-smtp (~> 0.3)
pry (~> 0.14)
pry-doc (~> 1.3)
puma (~> 5.6)
r18n-core (~> 5.0)
+ rackup (~> 0.2)
rainbow (~> 3.1)
rake (~> 13.0)
reek (~> 6.1)
- roda (~> 3.56)
+ roda (~> 3.61)
rspec (~> 3.11)
- rubocop (~> 1.29)
- rubocop-performance (~> 1.13)
+ rubocop (~> 1.36)
+ rubocop-performance (~> 1.15)
rubocop-rake (~> 0.6)
- rubocop-rspec (~> 2.11)
+ rubocop-rspec (~> 2.13)
rubocop-sequel (~> 0.3)
- sequel (~> 5.56)
+ ruby-mysql (~> 3.0)
+ sequel (~> 5.61)
simplecov (~> 0.21)
slim (~> 4.1)
- sqlite3 (~> 1.4)
+ sqlite3 (~> 1.5)
RUBY VERSION
ruby 3.1.2p20
diff --git a/README.org b/README.org
index a8e1b5a..bf14762 100644
--- a/README.org
+++ b/README.org
@@ -4,16 +4,126 @@ A tiny baby feeding tracking web app for their very first weeks.
* Configuration
-To work, this application needs the following environment variables to be
-exported:
+This application requires the following settings to be set before start:
+
+- ~base_uri~ :: Canonical URL of your public instance
+ (i.e. ~http://mysite.com~) without trailing slash.
+- ~publish_folder~ :: Where to upload your website (via the ~server:publish~
+ rake task, which use ~rsync~ internally). On your production server, this
+ settings does not need to be set.
+- ~session_secret~ :: Long string use as secret key to encrypt the session
+ cookies. The ~admin:session_secret~ rake task exists to help you generate a
+ session secret string.
+- ~sender_email~ :: Email address use as sender for email generated by the
+ application.
+- ~db_connect_uri~ :: Connection string to the database (to be given as this
+ to ~Sequel.connect~ method). It defaults to ~sqlite://db/biberon.db~.
+
+This settings can be given as part of a dedicated ~config.yml~ file at the
+root of your installation, as [[https://systemd.io/CREDENTIALS/][systemd service credentials]], as environment
+variables, or a combination of the three.
+
+When the application starts, it reads its configuration first from the
+config.yml file if any, then systemd credentials if any and finally from
+environment variables if any. The later taking precedence on the former.
+
+The following sections explain each method specificities.
+
+** With config.yml file
+
+The ~config.yml~ file must be put at the root of your installation,
+i.e. in the same place as this ~README.org~ file.
+
+For your production instance, this file can just contain the necessary
+settings, like the following example:
+
+#+begin_src yaml
+ ---
+ base_uri: "https://example.com"
+ session_secret: bliblabluverylongstringohmy
+ sender_email: someone@someserver.tld
+ db_connect_uri: "mysql://biberon:biberon@127.0.0.1:3306/biberon"
+#+end_src
+
+Beware while writing yaml file that period (~:~) must be escaped as part of
+the value, by enclosing the whole value with quotation marks.
+
+But an environment-based content is also supported, mainly for your local/dev
+instance, for example to run the test cases:
+
+#+begin_src yaml
+ ---
+ production: &default
+ base_uri: "https://example.com"
+ session_secret: bliblabluverylongstringohmy
+ sender_email: someone@someserver.tld
+ db_connect_uri: "mysql://biberon:biberon@127.0.0.1:3306/biberon"
+ development:
+ <<: *default
+ base_uri: "http://127.0.0.1:9292"
+ db_connect_uri: "sqlite://db/biberon.db"
+ test:
+ <<: *default
+ db_connect_uri: "sqlite://db/test.db"
+#+end_src
+
+The default environment (if nothing is given on the command line) is
+~development~.
+
+This method is the preferred one if your system on production server is
+running a version of systemd anterior to v250. In that case, you can you this
+solution, but be sure that only the user running the application is able to
+read you config file (~chmod 0600 config.yml~).
+
+This is also the preferred method for development environment, as it let you
+set different values for dev and test environments.
+
+** With systemd service credentials
+
+The application expect the settings to be part of a common yaml file named
+~secrets~. No multi-environment is supported in this file, as the systemd
+method is implied to be used only on production servers.
+
+You can prepare your secret file like this. First, create a local yaml file
+named ~secrets.yml~ (anything you want, the name is not important) and
+formatted like that:
+
+#+begin_src yaml
+ ---
+ base_uri: "https://example.com"
+ session_secret: bliblabluverylongstringohmy
+ sender_email: someone@someserver.tld
+ db_connect_uri: "mysql://biberon:biberon@127.0.0.1:3306/biberon"
+#+end_src
+
+Then, you can generate an encrypted version of it with the following command:
+
+#+begin_src
+ sudo systemd-creds encrypt -p --name=secrets secrets.yml -
+#+end_src
-- ~BIBERON_COOKIE_DOMAIN~ :: domain name of your public instance
- (i.e. ~mysite.com~), to be used to pin the session cookie to your website
- only.
-- ~BIBERON_PUBLISH_FOLDER~ :: Where to upload your website (via the
- ~server:publish~ rake task, which use ~rsync~ internally)
-- ~BIBERON_SESSION_SECRET~ :: Long string use as secret key to encrypt the
- session cookies.
+It will display the ~SetCredentialEncrypted~ option to add in your systemd
+file. See the "Run" > "Production instance" to see an exemple of such systemd
+service file.
+
+Warning, the ~--name~ switch on the ~systemd-creds~ command line *must be*
+~secrets~. Otherwise, your configuration file will not be loaded by the
+application.
+
+This method is the preferred one if your system on production server is
+running systemd v250+. In the contrary, you should go for the ~config.yml~
+solution, and ensuring only the user running the application is able to read
+you config file (~chmod 0600 config.yml~).
+
+** With environment variables
+
+You can use the following environment variables:
+
+- ~BIBERON_BASE_URI~ :: set the ~base_uri~ setting.
+- ~BIBERON_PUBLISH_FOLDER~ :: set the ~publish_folder~ setting.
+- ~BIBERON_SESSION_SECRET~ :: set the ~session_secret~ setting.
+- ~BIBERON_SENDER_EMAIL~ :: set the ~sender_email~ setting.
+- ~BIBERON_DB_CONNECT_URI~ :: set the ~db_connect_uri~ setting.
A quick way to do it locally, is to create a file named ~.env~ at the root of
this repository, containing the following lines:
@@ -21,29 +131,52 @@ this repository, containing the following lines:
#+begin_src
export BIBERON_PUBLISH_FOLDER=myserver.com:/opt/web/biberon/
export BIBERON_SESSION_SECRET='bliblabluverylongstringohmy'
- export BIBERON_COOKIE_DOMAIN=127.0.0.1
+ export BIBERON_BASE_URI=http://127.0.0.1:9292
+ export BIBERON_SENDER_EMAIL=someone@someserver.tld
+ export BIBERON_DB_CONNECT_URI=sqlite://db/biberon.db
#+end_src
-The ~admin:session_secret~ rake task exists to help you generate a session
-secret string.
+Environment variables should be avoided on production servers and only be used
+locally for development purpose.
* Run
-** Development server
+** Development instance
You can start a local server by using the following rake task.
#+begin_src shell
-rake server:start
+ rake server:start
#+end_src
-You should also try to build the various assets first:
+If you want to use an sqlite database, you can use ~sqlite://db/biberon.db~ as
+a value for the environment variable ~BIBERON_DB_CONNECT_URI~. If you prefer
+to use a mariadb database, you can use the following:
+~mysql://biberon:biberon@127.0.0.1:3306/biberon~. And if you need to quickly
+install a mariadb server, you can try to use a docker container for that:
-#+begin_src shell
-rake assets:all
+#+begin_src
+ docker image pull mariadb
+ docker volume create biberondb
+ docker create --name biberondb \
+ -p 3306:3306 \
+ -v biberondb:/var/lib/mysql \
+ -e MARIADB_USER=biberon \
+ -e MARIADB_PASSWORD=biberon \
+ -e MARIADB_DATABASE=biberon \
+ -e MARIADB_ROOT_PASSWORD=root mariadb
+ docker start biberondb
#+end_src
-** Production server
+Following the same idea, here is how you can start the maildev docker image to
+spin off a local mail server:
+
+#+begin_src
+ docker image pull maildev/maildev
+ docker run --rm -d -p 1080:1080 -p 1025:1025 maildev/maildev
+#+end_src
+
+** Production instance
You can adapt the following systemd service file:
@@ -59,9 +192,8 @@ You can adapt the following systemd service file:
Environment="PATH=/usr/local/share/rvm/gems/ruby-3.1.2@biberons/bin:/usr/local/share/rvm/gems/ruby-3.1.2@global/bin:/usr/local/share/rvm/rubies/ruby-3.1.2/bin:/usr/local/share/rvm/bin:/usr/local/bin:/usr/bin:/bin"
Environment="GEM_HOME=/usr/local/share/rvm/gems/ruby-3.1.2@biberons"
Environment="GEM_PATH=/usr/local/share/rvm/gems/ruby-3.1.2@biberons:/usr/local/share/rvm/gems/ruby-3.1.2@global"
- Environment="BIBERON_SESSION_SECRET=bliblabluverylongstringohmy"
- Environment="BIBERON_COOKIE_DOMAIN=mywebsite.com"
Environment="RACK_ENV=production"
+ SetCredentialEncrypted=secrets:Whxqht+dQJax1aZeCGLxmiAAAAABAAAADAAAABAAAABldG79XsgPAktHO+AAAAAATB9IK+aNiGQnilD…
WorkingDirectory=/opt/web/biberon
ExecStart=/usr/local/share/rvm/gems/ruby-3.1.2@biberons/bin/rackup -I ./lib -s puma -E production -P ./tmp/puma.pid -o 127.0.0.1 -p 9595
KillMode=control-group
@@ -70,6 +202,21 @@ You can adapt the following systemd service file:
WantedBy=multi-user.target
#+end_src
+** Test cases
+
+Currently, only models are tested with rspec. You can run the tests with the
+command ~rspec~.
+
+Warning, if you do not use a specific value for the ~db_connect_uri~ value in
+the test environment, it will fallback on the default configuration, what will
+remove all your data, as the test runner truncate all tables between test
+run. See the "Configuration" > "With config.yml file" section for more details
+on how to provide a configuration file supporting different value per
+environment.
+
* Credits
-- framework CSS: https://milligram.io/
+- CSS framework: https://milligram.io/
+- Icons: https://iconoir.com/
+- Baby bottle favicon: https://openclipart.org/detail/263557/baby-boy-accessories
+- Baby feeding background picture: https://undraw.co/
diff --git a/Rakefile b/Rakefile
index 9ef7e7a..19b9f3c 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,5 +1,10 @@
# frozen_string_literal: true
-require_relative 'lib/biberon/models'
+begin
+ require_relative 'lib/biberon/models'
+rescue Sequel::DatabaseConnectionError => e
+ puts "#{e}\n\n>> No database available. " \
+ "The admin:* and server:start tasks will probably fail.\n\n"
+end
Dir.glob('lib/tasks/*.rake').each { |r| import r }
diff --git a/assets/biberon.svg b/assets/biberon.svg
new file mode 100644
index 0000000..af9b269
--- /dev/null
+++ b/assets/biberon.svg
@@ -0,0 +1,1217 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="34.495369mm"
+ height="85.692451mm"
+ viewBox="0 0 122.22769 303.63466"
+ id="svg2"
+ version="1.1"
+ inkscape:version="1.2.1 (9c6d41e410, 2022-07-14, custom)"
+ sodipodi:docname="biberon.svg"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <defs
+ id="defs4">
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter5263">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood5265" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite5267" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur5269" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset5271" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite5273" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter6103">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood6105" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite6107" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur6109" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset6111" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite6113" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4747">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4749" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4751" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4753" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4755" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4757" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4756">
+ <feFlood
+ flood-opacity="0.588235"
+ flood-color="rgb(255,120,0)"
+ result="flood"
+ id="feFlood4758" />
+ <feComposite
+ in="SourceGraphic"
+ in2="flood"
+ operator="in"
+ result="composite1"
+ id="feComposite4760" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4762" />
+ <feOffset
+ dx="2"
+ dy="2"
+ result="offset"
+ id="feOffset4764" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4766" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3904">
+ <feFlood
+ flood-opacity="0.588235"
+ flood-color="rgb(255,120,0)"
+ result="flood"
+ id="feFlood3906" />
+ <feComposite
+ in="SourceGraphic"
+ in2="flood"
+ operator="in"
+ result="composite1"
+ id="feComposite3908" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3910" />
+ <feOffset
+ dx="2"
+ dy="2"
+ result="offset"
+ id="feOffset3912" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3914" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3916">
+ <feFlood
+ flood-opacity="0.588235"
+ flood-color="rgb(255,120,0)"
+ result="flood"
+ id="feFlood3918" />
+ <feComposite
+ in="SourceGraphic"
+ in2="flood"
+ operator="in"
+ result="composite1"
+ id="feComposite3920" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3922" />
+ <feOffset
+ dx="2"
+ dy="2"
+ result="offset"
+ id="feOffset3924" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3926" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4111">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4113" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4115" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4117" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4119" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4121" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4123">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4125" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4127" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4129" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4131" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4133" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4135">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4137" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4139" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4141" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4143" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4145" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4147">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4149" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4151" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4153" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4155" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4157" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4171">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4173" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4175" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4177" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4179" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4181" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3861">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood3863" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite3865" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3867" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset3869" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3871" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3919">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood3921" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite3923" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3925" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset3927" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3929" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3890">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood3892" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite3894" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3896" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset3898" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3900" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3875">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood3877" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite3879" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3881" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset3883" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3885" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3920">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood3922" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite3924" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3926" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset3928" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3930" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4078">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4080" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4082" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4084" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4086" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4088" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3862">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood3864" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite3866" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3868" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset3870" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3872" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3874">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood3876" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite3878" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3880" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset3882" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3884" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3857">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood3859" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite3861" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3863" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset3865" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3867" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4588">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4590" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4592" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4594" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4596" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4598" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4200">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4202" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4204" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4206" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4208" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4210" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter3917">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood3919" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite3921" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur3923" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset3925" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite3927" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4774">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4776" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4778" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4780" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4782" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4784" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4442">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4444" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4446" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4448" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4450" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4452" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4890">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4892" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4894" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4896" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4898" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4900" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4363">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4365" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4367" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4369" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4371" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4373" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4406">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4408" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4410" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4412" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4414" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4416" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4457">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4459" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4461" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4463" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4465" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4467" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4507">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4509" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4511" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4513" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4515" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4517" />
+ </filter>
+ <filter
+ style="color-interpolation-filters:sRGB"
+ inkscape:label="Drop Shadow"
+ id="filter4545">
+ <feFlood
+ flood-opacity="1"
+ flood-color="rgb(38,50,56)"
+ result="flood"
+ id="feFlood4547" />
+ <feComposite
+ in="flood"
+ in2="SourceGraphic"
+ operator="in"
+ result="composite1"
+ id="feComposite4549" />
+ <feGaussianBlur
+ in="composite1"
+ stdDeviation="2"
+ result="blur"
+ id="feGaussianBlur4551" />
+ <feOffset
+ dx="3"
+ dy="1.5"
+ result="offset"
+ id="feOffset4553" />
+ <feComposite
+ in="SourceGraphic"
+ in2="offset"
+ operator="over"
+ result="composite2"
+ id="feComposite4555" />
+ </filter>
+ <inkscape:path-effect
+ effect="skeletal"
+ id="path-effect5563"
+ is_visible="true"
+ pattern="M 0,5 C 0,2.24 2.24,0 5,0 7.76,0 10,2.24 10,5 10,7.76 7.76,10 5,10 2.24,10 0,7.76 0,5 Z"
+ copytype="single_stretched"
+ prop_scale="1"
+ scale_y_rel="false"
+ spacing="0"
+ normal_offset="0"
+ tang_offset="0"
+ prop_units="false"
+ vertical_pattern="false"
+ fuse_tolerance="0"
+ lpeversion="0"
+ hide_knot="false" />
+ <inkscape:path-effect
+ effect="skeletal"
+ id="path-effect5570"
+ is_visible="true"
+ pattern="m -7.1428572,9.2857143 c 0,-2.76 2.24,-5 5,-5 2.76000005,0 5,2.24 5,5 0,2.7599997 -2.23999995,4.9999997 -5,4.9999997 -2.76,0 -5,-2.24 -5,-4.9999997 z"
+ copytype="single_stretched"
+ prop_scale="0.5"
+ scale_y_rel="false"
+ spacing="0"
+ normal_offset="0"
+ tang_offset="0"
+ prop_units="false"
+ vertical_pattern="false"
+ fuse_tolerance="0"
+ lpeversion="0"
+ hide_knot="false" />
+ <inkscape:path-effect
+ fuse_tolerance="0"
+ vertical_pattern="false"
+ prop_units="false"
+ tang_offset="0"
+ normal_offset="0"
+ spacing="0"
+ scale_y_rel="false"
+ prop_scale="0.5"
+ copytype="single_stretched"
+ pattern="m -333.14286,9.285714 c 0,-2.7599997 2.24,-4.9999997 5,-4.9999997 2.76,0 5,2.24 5,4.9999997 0,2.76 -2.24,5 -5,5 -2.76,0 -5,-2.24 -5,-5 z"
+ is_visible="true"
+ id="path-effect5574"
+ effect="skeletal"
+ lpeversion="0"
+ hide_knot="false" />
+ </defs>
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="2.4086394"
+ inkscape:cx="14.115853"
+ inkscape:cy="146.76336"
+ inkscape:document-units="px"
+ inkscape:current-layer="layer1"
+ showgrid="true"
+ inkscape:window-width="1920"
+ inkscape:window-height="1009"
+ inkscape:window-x="0"
+ inkscape:window-y="37"
+ inkscape:window-maximized="1"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ inkscape:showpageshadow="0"
+ inkscape:pagecheckerboard="0"
+ inkscape:deskcolor="#d1d1d1" />
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="1. réteg"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-745.55475,612.94585)">
+ <path
+ inkscape:connector-curvature="0"
+ id="rect3996"
+ d="m 805.8867,-612.92198 c -18.76007,0.10907 -32.98893,20.66663 -28.02344,38.39843 0.33383,7.30951 2.29938,16.08623 -4.12801,21.58661 -3.03114,4.00809 -6.00398,8.15086 -7.88371,12.84504 -12.53378,0.83787 -21.33839,13.47523 -19.83789,25.55664 -0.35614,5.17788 1.80748,10.38302 3.26758,14.88476 -6.65639,17.98258 -2.09353,37.42528 -3.38672,55.94141 0.28064,32.95733 -1.14587,66.03657 1.06836,98.91406 4.78961,22.54322 28.55806,38.30704 51.19312,35.05992 17.58473,-0.0467 37.77257,2.65862 52.19555,-9.79429 13.9607,-10.94348 19.07032,-29.69075 16.98106,-46.77141 -0.449,-42.05835 1.21606,-84.2143 -0.98887,-126.20906 -1.13834,-3.86998 -3.95991,-7.92705 -0.92578,-11.71485 5.07015,-12.80661 0.68562,-31.28241 -14.01758,-35.07226 -3.15059,-0.11031 -4.77497,-1.31809 -5.47656,-4.45118 -4.36201,-7.57781 -13.05504,-13.94791 -11.01563,-23.81054 1.31467,-11.36986 2.85028,-24.5688 -5.45703,-33.96485 -5.56626,-7.11513 -14.4337,-11.7993 -23.56445,-11.39843 z"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ <rect
+ style="fill:#e1f5fe;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.259761;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect3854"
+ width="103.16204"
+ height="199.64453"
+ x="755.02612"
+ y="-518.50775"
+ ry="36.326172" />
+ <path
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.259761;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 806.60716,-603.71131 a 20.335976,21.548673 0 0 0 -20.33599,21.54859 20.335976,21.548673 0 0 0 1.07611,6.83309 v 19.10207 l -12.73743,18.02462 h -0.0862 v 0.122 6.98059 h 64.16695 v -6.98059 -0.122 h -0.0862 l -12.7376,-18.02462 v -19.07989 a 20.335976,21.548673 0 0 0 1.07611,-6.85527 20.335976,21.548673 0 0 0 -20.33583,-21.54859 z"
+ id="rect3858"
+ inkscape:connector-curvature="0" />
+ <rect
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect3871-7"
+ width="38.407452"
+ height="4.4530382"
+ x="787.40344"
+ y="-461.36041"
+ ry="2.2265191" />
+ <rect
+ ry="2.2265191"
+ y="-448.37241"
+ x="787.40344"
+ height="4.4530382"
+ width="38.407452"
+ id="rect3873-0"
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ <rect
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect3875-0"
+ width="38.407452"
+ height="4.4530382"
+ x="787.40344"
+ y="-435.38437"
+ ry="2.2265191" />
+ <rect
+ ry="2.2265191"
+ y="-422.39633"
+ x="787.40344"
+ height="4.4530382"
+ width="38.407452"
+ id="rect3877-7"
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ <rect
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect3879-1"
+ width="38.407452"
+ height="4.4530382"
+ x="787.40344"
+ y="-409.40833"
+ ry="2.2265191" />
+ <rect
+ ry="2.2265191"
+ y="-396.93982"
+ x="787.40344"
+ height="4.4530382"
+ width="38.407452"
+ id="rect3881-5"
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ <rect
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect3883-4"
+ width="38.407452"
+ height="4.4530382"
+ x="787.40344"
+ y="-383.95178"
+ ry="2.2265191" />
+ <rect
+ ry="2.2265191"
+ y="-370.96375"
+ x="787.40344"
+ height="4.4530382"
+ width="38.407452"
+ id="rect3885-1"
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
+ <path
+ style="fill:#b3e5fc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 801.30404,-518.50776 c 20.1247,0 36.32588,16.18664 36.32588,36.29264 v 126.87393 c 0,20.10599 -16.20118,36.29263 -36.32588,36.29263 h 20.0386 c 20.12469,0 36.32588,-16.18664 36.32588,-36.29263 v -126.87393 c 0,-20.106 -16.20119,-36.29264 -36.32588,-36.29264 z"
+ id="rect3965"
+ inkscape:connector-curvature="0" />
+ <rect
+ style="fill:#4fc3f7;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.259761;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect3856"
+ width="102.41986"
+ height="31.448196"
+ x="755.39722"
+ y="-530.90204"
+ ry="12.305814" />
+ <path
+ style="fill:#03a9f4;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 827.92188,-530.90219 c 6.81742,0 12.30565,5.42397 12.30565,12.16094 v 6.75581 c 0,6.73697 -5.48823,12.16044 -12.30565,12.16044 h 17.07013 c 6.81742,0 12.30564,-5.42347 12.30564,-12.16044 v -6.75581 c 0,-6.73697 -5.48822,-12.16094 -12.30564,-12.16094 z"
+ id="rect3960"
+ inkscape:connector-curvature="0" />
+ <path
+ style="fill:#81d4fa;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ d="m 806.08779,-603.71124 a 20.335976,21.438545 0 0 0 -2.62246,0.2014 17.278225,21.438545 0 0 1 15.07625,21.23693 17.278225,21.438545 0 0 1 -0.91423,6.82021 v 18.98271 l 10.82217,17.93232 h 0.0736 v 0.12114 6.94488 h 9.64818 v -6.94488 -0.12114 h -0.0862 l -12.73739,-17.93232 v -18.98271 a 20.335976,21.438545 0 0 0 1.07607,-6.82021 20.335976,21.438545 0 0 0 -20.3359,-21.43833 z"
+ id="path3970"
+ inkscape:connector-curvature="0" />
+ <ellipse
+ style="fill:#e1f5fe;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.779282;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="path3975"
+ cx="512.45483"
+ cy="-845.83044"
+ rx="5.9373837"
+ ry="9.2771616"
+ transform="rotate(22.251334)" />
+ </g>
+</svg>
diff --git a/assets/centigram b/assets/centigram
new file mode 160000
+Subproject 3884ff46395c7d8cafc31a60c52b115623906c3
diff --git a/assets/style.sass b/assets/style.sass
new file mode 100644
index 0000000..a019a77
--- /dev/null
+++ b/assets/style.sass
@@ -0,0 +1,88 @@
+@import "centigram/src/centigram.sass"
+
+.center-text
+ text-align: center
+
+.home-hero
+ margin: 2rem auto 5rem
+ width: 50%
+
+ h1
+ color: $color-primary
+ h2
+ font-size: 2.2rem
+
+nav
+ ul
+ list-style: none
+ margin-bottom: 1rem
+ li
+ display: inline
+ li::before
+ content: ' - '
+ li:first-child::before
+ content: ''
+
+footer
+ p
+ font-size: 1.2rem
+ margin-bottom: 1rem
+ text-align: center
+
+.geneston
+ text-decoration: underline dotted
+
+table.biblist form, table.biblist fieldset, table.biblist button
+ margin-bottom: 0
+
+canvas.chart
+ margin-bottom: 4rem
+
+.edit-toggle
+ cursor: pointer
+
+.edit-target
+ display: none
+
+.iconoir
+ display: inline-block
+ font-style: normal
+ font-variant: normal
+ text-rendering: auto
+ line-height: 1
+ margin-bottom: -0.2rem
+
+h1 .iconoir
+ font-size: 2rem
+
+.row .column.column-offset-15
+ margin-left: 15%
+
+.decorated
+ background-color: #ffffff
+ background-image: url('/img/undraw_baby_feeding.svg')
+ background-repeat: no-repeat
+ background-position: bottom right
+ background-size: 18rem
+ border-bottom: 1px solid $color-quaternary
+ margin-bottom: 3rem
+
+ input[type='text'], input[type='password'], input[type='email']
+ background-color: #ffffffbb
+
+ input[type='reset'].button-outline
+ background-color: #ffffffbb
+
+@media (max-width: 40.0rem)
+ .home-hero
+ width: 100%
+
+ .row .column.column-offset-15
+ margin-left: 0
+
+@media (min-width: 40.0rem) and (max-width: 80.0rem)
+ .home-hero
+ width: 80%
+
+ .row .column.column-33
+ max-width: 50%
diff --git a/db/migrations/202210072143_add_users.rb b/db/migrations/202210072143_add_users.rb
new file mode 100644
index 0000000..dc493cc
--- /dev/null
+++ b/db/migrations/202210072143_add_users.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ up do
+ create_table :users do
+ primary_key :id
+ String :email, null: false, unique: true,
+ unique_constraint_name: :users_email_uq
+ String :password_hash, null: false
+ String :reset_token
+ end
+
+ # Migrate any data from the babies table to the users one
+ password_hash = from(:babies).get(:password_hash)
+ if password_hash
+ from(:users).insert(email: 'placeholder', password_hash: password_hash)
+ puts 'A placeholder user has been created with the first baby password.'
+ puts 'Think about updating its email address with a regular one.'
+ end
+
+ add_column :babies, :user_id, Integer
+ # Hydrate this new columns with the placeholder user
+ from(:babies).update(user_id: 1) if password_hash
+
+ # We can now set this new column as a foreign key
+ alter_table :babies do
+ add_foreign_key [:user_id], :users,
+ null: false, on_delete: :cascade,
+ name: :babies_user_id_fkey
+ drop_column :password_hash
+ end
+ end
+
+ down do
+ alter_table :babies do
+ add_column :password_hash, String, null: false
+ drop_foreign_key [:user_id], name: :babies_user_id_fkey
+ end
+
+ from(:babies).update(
+ password_hash: from(:users).select(:password_hash).where(
+ id: Sequel[:babies][:user_id]
+ )
+ )
+
+ drop_column :babies, :user_id
+ drop_table :users
+ end
+end
diff --git a/lib/biberon.rb b/lib/biberon.rb
index c578b75..ba6b89f 100644
--- a/lib/biberon.rb
+++ b/lib/biberon.rb
@@ -12,150 +12,72 @@ module Biberon
class App < Roda
plugin :render, engine: 'slim', layout: 'layout'
plugin :public, gzip: true, brotli: true
+ plugin :named_routes
plugin :route_csrf
- plugin :sessions, secret: ENV.fetch('BIBERON_SESSION_SECRET'),
- key: 'biberon_auth'
- plugin :cookies, domain: ENV.fetch('BIBERON_COOKIE_DOMAIN'),
- path: '/', httponly: true,
- secure: ENV.fetch('RACK_ENV', 'development') != 'development' # rubocop:disable Layout/LineLength
+ plugin :sessions,
+ secret: CONFIG.get(:session_secret),
+ key: 'biberon_auth',
+ cookie_options: {
+ domain: CONFIG.get(:cookie_domain),
+ secure: CONFIG.production?
+ }
+ plugin :cookies,
+ domain: CONFIG.get(:cookie_domain),
+ path: '/', httponly: true,
+ secure: CONFIG.production?
+
+ http_headers = {
+ 'Content-Type' => 'text/html',
+ 'X-Frame-Options' => 'deny',
+ 'X-Content-Type-Options' => 'nosniff',
+ 'X-XSS-Protection' => '1; mode=block'
+ }
+ if CONFIG.production?
+ http_headers['Strict-Transport-Security'] = 'max-age=16070400;'
+ end
+ plugin :default_headers, http_headers
+
+ plugin :content_security_policy do |csp|
+ csp.default_src :none
+ csp.connect_src :self
+ csp.font_src :self
+ csp.form_action :self
+ csp.img_src :self, 'data:'
+ csp.script_src :self
+ csp.style_src :self
+ csp.frame_ancestors :none
+ end
# Log to stderr
plugin :common_logger
- route do |r|
- r.public
+ route do |routing|
+ routing.public
check_csrf!
- @_cookie = parse_cookie
-
- R18n.set cookie('l', 'fr')
- @settings = cookie('c', 'c,t,u,v,w,l,r').split(',')
+ load_settings
- r.root do
- r.redirect "/#{session['baby_id']}" if session['baby_id']
+ routing.root do
+ routing.redirect "/babies/#{session['baby_id']}" if session['baby_id']
+ routing.redirect '/babies' if session['user_id']
@title = R18n.t.home
view 'index'
end
- r.on 'session' do
- r.post 'create' do
- params = request.params
- clear_session
- if params['name'] && !params['name'].empty? && params['password']
- baby = ::Biberon::Baby.first(name: params['name'])
- if baby.check_password(params['password'])
- session['baby_id'] = baby.id
- r.redirect "/#{baby.id}"
- end
- end
- r.redirect '/'
- end
-
- r.get 'logout' do
- clear_session
- r.redirect '/'
- end
- end
+ routing.on('users') { routing.route 'users' }
+ routing.on('password') { routing.route 'password' }
- r.is 'settings' do
- @title = R18n.t.settings.settings
-
- r.get { view 'settings' }
-
- r.post do
- params = request.params
- settings = []
- if params['lang'] && ['fr', 'en'].include?(params['lang'])
- settings << "l:#{params['lang']}"
- end
- opts = []
- ['comment', 'temp', 'urines', 'vitamind', 'weight',
- 'left_breast', 'right_breast'].each do |opt_name|
- opts << opt_name[0] if params[opt_name]
- end
- settings << "c:#{opts.join(',')}"
- response.set_cookie('biberon_settings', settings.join(';'))
- r.redirect '/'
- end
- end
+ @user = nil
+ @user = User[session['user_id']] if session['user_id']
- r.on Integer do |baby_id|
- r.redirect '/' unless session['baby_id'] == baby_id
- @baby = ::Biberon::Baby[baby_id]
- r.redirect '/' unless @baby
- @title = @baby.name
- @errors = nil
-
- r.get true do
- @today = Date.today
- view 'baby'
- end
-
- r.get 'json' do
- response['Content-Type'] = 'application/json'
- @baby.statistics.to_json
- end
-
- r.get('graph') { view 'graph' }
-
- r.get Integer do |date|
- @today = Date.strptime(date.to_s, '%Y%m%d')
- view 'baby'
- end
-
- r.post 'add' do
- params = request.params
- params['baby_id'] = @baby.id
- bib = ::Biberon::Biberon.new
- bib.set_fields(
- params,
- ['baby_id', 'quantity', 'weight', 'comment',
- 'feces', 'urines', 'vitamind', 'temperature',
- 'left_breast', 'right_breast']
- )
- bib.datetime = DateTime.strptime(
- "#{params['date']} #{params['time']}",
- '%Y%m%d %H:%M'
- )
- if bib.valid? && bib.save_changes
- redirect_to_date(r, @baby.id, params['date'])
- end
- @errors = bib.errors
- @today = Date.strptime(params['date'], '%Y%m%d')
- view 'baby'
- end
-
- r.post 'delete' do
- params = request.params
- bib_id = DateTime.strptime(
- "#{params['date']} #{params['time']}",
- '%Y%m%d %H:%M'
- )
- bib = @baby.biberons_dataset.where(datetime: bib_id)
- bib&.destroy
- redirect_to_date(r, @baby.id, params['date'])
- end
- end
- end
+ routing.route 'settings'
- private
+ routing.on('biberons') { routing.route 'biberons' }
- def redirect_to_date(routing, baby_id, date)
- route = "/#{baby_id}"
- route = "#{route}/#{date}" if date != Date.today.strftime('%Y%m%d')
- routing.redirect route
+ routing.on('babies') { routing.route 'babies' }
end
- def cookie(key, default)
- @_cookie[key] || default
- end
-
- def parse_cookie
- bib_settings = request.cookies['biberon_settings'] || ''
- bib_settings.split(';').to_h do |cook|
- cook.split(':').tap { |data| data[1] ||= '-' }
- end
- end
+ private
# rubocop:disable Layout/LineLength
# :reek:FeatureEnvy
@@ -164,5 +86,15 @@ module Biberon
$stderr << "#{env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-'} - #{env['REMOTE_USER'] || '-'} [#{Time.now.strftime('%d/%b/%Y:%H:%M:%S %z')}] [#{level.to_s.upcase}] #{message}\n"
end
# rubocop:enable Layout/LineLength
+
+ def redirect_to_date(routing, baby_id, date)
+ route = "/babies/#{baby_id}"
+ if date != Date.today.strftime('%Y%m%d')
+ route = "/babies/#{route}/#{date}"
+ end
+ routing.redirect route
+ end
end
end
+
+Dir['biberon/routes/*.rb', base: __dir__].each { |f| require_relative f }
diff --git a/lib/biberon/config.rb b/lib/biberon/config.rb
new file mode 100644
index 0000000..e9943c6
--- /dev/null
+++ b/lib/biberon/config.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'uri'
+require 'yaml'
+
+module Biberon
+ # Centralize App settings discovery
+ class Config
+ def initialize
+ @config = {
+ base_uri: '',
+ session_secret: nil, # must fail if empty
+ db_connect_uri: 'sqlite://db/biberon.db',
+ sender_email: 'no-reply@localhost',
+ _rack_env: ENV.fetch('RACK_ENV', 'development')
+ }
+ load_config_file
+ load_systemd_creds
+ load_environment_vars
+ enrich_settings
+ @config.freeze
+ end
+
+ def get(key, default = nil)
+ @config.fetch(key, default)
+ end
+
+ def production?
+ get(:_rack_env) == 'production'
+ end
+
+ private
+
+ def load_config_file
+ conf_file = File.expand_path('../../config.yml', __dir__)
+ return {} unless conf_file
+ # Local config file may contains environment related settings.
+ local_config = YAML.load_file(
+ conf_file, symbolize_names: true, aliases: true
+ )
+ @config.merge! local_config.fetch(get(:_rack_env).to_sym, local_config)
+ end
+
+ def load_systemd_creds
+ systemd_creds_dir = ENV.fetch('CREDENTIALS_DIRECTORY', nil)
+ return {} unless systemd_creds_dir
+ systemd_creds_file = File.expand_path('secrets', systemd_creds_dir)
+ return {} unless systemd_creds_file
+ # Systemd creds file is expected to directly contains the settings to
+ # use.
+ @config.merge! YAML.load_file(systemd_creds_file, symbolize_names: true)
+ end
+
+ def load_environment_vars
+ @config.tap do |conf|
+ [:base_uri, :session_secret, :db_connect_uri].each do |var|
+ value = ENV.fetch("BIBERON_#{var.to_s.upcase}", nil)
+ next unless value
+ conf[var] = value
+ end
+ end
+ end
+
+ def enrich_settings
+ # For now only prepare cookie domain
+ @config[:cookie_domain] = URI(get(:base_uri)).host
+ end
+ end
+
+ CONFIG = Config.new
+end
diff --git a/lib/biberon/helpers/emails.rb b/lib/biberon/helpers/emails.rb
new file mode 100644
index 0000000..d240598
--- /dev/null
+++ b/lib/biberon/helpers/emails.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'mail'
+
+module Biberon
+ # This module contains method related to sending emails to end-users
+ module EmailsHelper
+ class << self
+ def send_email(email, subject, bodies)
+ from_addr = CONFIG.get(:sender_email)
+ mail = Mail.new(from: from_addr, to: email, subject: subject)
+ mail = build_body mail, bodies
+ deliver_email mail
+ end
+
+ def email_template(template)
+ return nil unless R18n.t.emails[template].body.translated?
+ bodies = { text: R18n.t.emails[template].body }
+ return bodies unless R18n.t.emails[template].body_html.translated?
+ bodies[:html] = R18n.t.emails[template].body_html
+ bodies
+ end
+
+ private
+
+ def prepare_email_body(mail, bodies)
+ mail.html_part = Mail::Part.new do
+ content_type 'text/html; charset=UTF-8'
+ body bodies[:html]
+ end
+ return mail unless bodies.has_key? :text
+ mail.text_part = Mail::Part.new do
+ content_type 'text/plain; charset=UTF-8'
+ body bodies[:text]
+ end
+ mail
+ end
+
+ def build_body(mail, bodies)
+ bodies ||= ''
+ if bodies.is_a? Hash
+ return prepare_email_body(mail, bodies) if bodies.has_key? :html
+ bodies = bodies[:text] || ''
+ end
+ mail.charset = 'UTF-8'
+ mail.body = bodies
+ mail
+ end
+
+ def deliver_email(mail)
+ if CONFIG.production?
+ mail.delivery_method :sendmail
+ else
+ # Maildev params
+ smtp_dev_opts = {
+ address: '127.0.0.1', port: 1025,
+ openssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
+ }
+ mail.delivery_method :smtp, smtp_dev_opts
+ end
+ mail.deliver
+ end
+ end
+ end
+end
diff --git a/lib/biberon/helpers/passwords.rb b/lib/biberon/helpers/passwords.rb
new file mode 100644
index 0000000..0fe1aea
--- /dev/null
+++ b/lib/biberon/helpers/passwords.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'securerandom'
+require_relative './emails'
+
+module Biberon
+ # Business logic related to reset password
+ module PasswordsHelper
+ class << self
+ def new_password(params)
+ email = params['email']
+ return unless email
+ user = User.first(email: email)
+ return unless user
+ reset_token = SecureRandom.alphanumeric(64)
+ user.update(reset_token: reset_token)
+ EmailsHelper.send_email(
+ email,
+ R18n.t.emails.password_change.subject,
+ prepare_reset_mail_body(reset_token)
+ )
+ end
+
+ def check_reset_token(params)
+ token = params['token']
+ return nil unless token
+ User.first(reset_token: token)
+ end
+
+ def change_password(params)
+ return nil unless check_change_password_params(params)
+ user = check_reset_token(params)
+ return nil unless user
+ user.update(reset_token: nil, password: params['password'])
+ end
+
+ private
+
+ def check_change_password_params(params)
+ ['password', 'confirm-password'].each do |pass_key|
+ return false unless params[pass_key]
+ end
+ params['password'] == params['confirm-password']
+ end
+
+ def prepare_reset_mail_body(reset_token)
+ templates = EmailsHelper.email_template(:password_change)
+ templates&.transform_values do |body|
+ body.gsub('{{base_uri}}', CONFIG.get(:base_uri))
+ .gsub('{{token}}', reset_token)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/biberon/models.rb b/lib/biberon/models.rb
index f1f4695..5e9a2d2 100644
--- a/lib/biberon/models.rb
+++ b/lib/biberon/models.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
require 'sequel'
+require_relative 'config'
module Biberon
- DB = Sequel.connect('sqlite://db/biberon.db')
+ DB = Sequel.connect(CONFIG.get(:db_connect_uri))
end
Dir['models/*.rb', base: __dir__].each { |f| require_relative f }
diff --git a/lib/biberon/models/baby.rb b/lib/biberon/models/baby.rb
index 22c838f..0f92253 100644
--- a/lib/biberon/models/baby.rb
+++ b/lib/biberon/models/baby.rb
@@ -1,29 +1,17 @@
# frozen_string_literal: true
-require 'bcrypt'
-
module Biberon
# Baby model, used to separate various babies and add some sort of access
# right to the data.
class Baby < Sequel::Model(:babies)
- include BCrypt
-
plugin :validation_helpers
one_to_many :biberons, order: :datetime
+ many_to_one :user
def validate
super
- validates_presence [:name, :password_hash]
- end
-
- def password=(new_pass)
- pass_hash = Password.create(new_pass)
- self.password_hash = pass_hash
- end
-
- def check_password(password)
- Password.new(password_hash) == password
+ validates_presence [:name, :user]
end
def age(date)
diff --git a/lib/biberon/models/biberon.rb b/lib/biberon/models/biberon.rb
index b55015c..52566ed 100644
--- a/lib/biberon/models/biberon.rb
+++ b/lib/biberon/models/biberon.rb
@@ -9,7 +9,10 @@ module Biberon
def validate
super
errors.add(:baby, R18n.t.biberon.validations.baby) if baby.nil?
- comment = nil if comment&.strip == ''
+ if datetime.nil?
+ errors.add(:datetime, R18n.t.biberon.validations.datetime)
+ end
+ self.comment = nil if comment&.strip == ''
one_off = [quantity, temperature, weight, comment,
left_breast, right_breast].compact
return unless one_off.empty?
diff --git a/lib/biberon/models/user.rb b/lib/biberon/models/user.rb
new file mode 100644
index 0000000..d88c7c3
--- /dev/null
+++ b/lib/biberon/models/user.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'bcrypt'
+require 'securerandom'
+
+module Biberon
+ # Contains necessary stuff to authenticate web users
+ class User < Sequel::Model
+ include BCrypt
+ plugin :validation_helpers
+
+ one_to_many :babies
+
+ def validate
+ super
+ validates_presence [:email, :password_hash]
+ end
+
+ def password=(new_pass)
+ pass_hash = Password.create(new_pass)
+ self.password_hash = pass_hash
+ end
+
+ def check_password(password)
+ Password.new(password_hash) == password
+ end
+
+ class << self
+ def authenticate(params)
+ unless params.has_key?('email') && params.has_key?('password')
+ return nil
+ end
+ user = first(email: params['email'])
+ return nil unless user
+ return nil unless user.check_password params['password']
+ user
+ end
+
+ def generate_password
+ SecureRandom.alphanumeric(25)
+ end
+ end
+ end
+end
diff --git a/lib/biberon/routes/babies.rb b/lib/biberon/routes/babies.rb
new file mode 100644
index 0000000..75ef8e9
--- /dev/null
+++ b/lib/biberon/routes/babies.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Biberon
+ class App
+ route 'babies' do |routing|
+ routing.get true do
+ @title = R18n.t.your_babies
+ view 'babies/index'
+ end
+
+ routing.post true do
+ baby = @user.add_baby(name: request.params['name'])
+ routing.redirect "/babies/#{baby.id}"
+ end
+
+ routing.on Integer do |baby_id|
+ routing.redirect '/' unless @user
+ @baby = @user.babies_dataset[baby_id]
+ routing.redirect '/' unless @baby
+ session['baby_id'] = @baby.id
+
+ @title = @baby.name
+ @errors = nil
+
+ routing.get true do
+ @today = Date.today
+ view 'babies/baby'
+ end
+
+ routing.post true do
+ @baby.update(name: request.params['name'])
+ routing.redirect "/babies/#{@baby.id}"
+ end
+
+ routing.get 'json' do
+ response['Content-Type'] = 'application/json'
+ @baby.statistics.to_json
+ end
+
+ routing.get('graph') { view 'babies/graph' }
+
+ routing.get Integer do |date|
+ @today = Date.strptime(date.to_s, '%Y%m%d')
+ view 'babies/baby'
+ end
+ end
+ end
+ end
+end
diff --git a/lib/biberon/routes/biberons.rb b/lib/biberon/routes/biberons.rb
new file mode 100644
index 0000000..1dcd157
--- /dev/null
+++ b/lib/biberon/routes/biberons.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Biberon
+ class App
+ route 'biberons' do |routing|
+ routing.redirect '/' unless @user && session['baby_id']
+ @baby = @user.babies_dataset[session['baby_id']]
+
+ routing.post 'add' do
+ params = request.params
+ params['baby_id'] = @baby.id
+ bib = Biberon.new
+ bib.set_fields(
+ params,
+ ['baby_id', 'quantity', 'weight', 'comment',
+ 'feces', 'urines', 'vitamind', 'temperature',
+ 'left_breast', 'right_breast']
+ )
+ bib.datetime = DateTime.strptime(
+ "#{params['date']} #{params['time']}",
+ '%Y%m%d %H:%M'
+ )
+ if bib.valid? && bib.save_changes
+ redirect_to_date(routing, @baby.id, params['date'])
+ end
+ @errors = bib.errors
+ @today = Date.strptime(params['date'], '%Y%m%d')
+ view 'babies/baby'
+ end
+
+ routing.post 'delete' do
+ params = request.params
+ bib_id = DateTime.strptime(
+ "#{params['date']} #{params['time']}",
+ '%Y%m%d %H:%M'
+ )
+ bib = @baby.biberons_dataset.where(datetime: bib_id)
+ bib&.destroy
+ redirect_to_date(routing, @baby.id, params['date'])
+ end
+ end
+ end
+end
diff --git a/lib/biberon/routes/password.rb b/lib/biberon/routes/password.rb
new file mode 100644
index 0000000..12b1cba
--- /dev/null
+++ b/lib/biberon/routes/password.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require_relative '../helpers/passwords'
+
+module Biberon
+ class App
+ route 'password' do |routing|
+ routing.is 'new' do
+ routing.get do
+ @title = R18n.t.password.title
+ view 'password/new'
+ end
+
+ routing.post do
+ PasswordsHelper.new_password(request.params)
+ session['password_change'] = 'token_sent'
+ routing.redirect '/'
+ end
+ end
+
+ routing.is 'change' do
+ routing.get do
+ @title = R18n.t.password.title
+ auth_detail = PasswordsHelper.check_reset_token(request.params)
+ @token = auth_detail&.reset_token
+ view 'password/change'
+ end
+
+ routing.post do
+ ad = PasswordsHelper.change_password(request.params)
+ session['password_change'] = 'error'
+ routing.redirect '/password/new' if ad.nil?
+ clear_session
+ session['password_change'] = 'reset'
+ routing.redirect '/'
+ end
+ end
+ end
+ end
+end
diff --git a/lib/biberon/routes/settings.rb b/lib/biberon/routes/settings.rb
new file mode 100644
index 0000000..1d8024b
--- /dev/null
+++ b/lib/biberon/routes/settings.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Biberon
+ class App
+ route 'settings' do |routing|
+ routing.is 'settings' do
+ @title = R18n.t.settings.settings
+
+ routing.get { view 'settings' }
+
+ routing.post do
+ params = request.params
+ settings = []
+ if params['lang'] && ['fr', 'en'].include?(params['lang'])
+ settings << "l:#{params['lang']}"
+ end
+ opts = []
+ ['comment', 'temp', 'urines', 'vitamind', 'weight',
+ 'left_breast', 'right_breast'].each do |opt_name|
+ opts << opt_name[0] if params[opt_name]
+ end
+ settings << "c:#{opts.join(',')}"
+ response.set_cookie('biberon_settings', settings.join(';'))
+ routing.redirect '/'
+ end
+ end
+ end
+
+ private
+
+ def load_settings
+ @_cookie = parse_cookie
+ R18n.set cookie('l', 'fr')
+ @settings = cookie('c', 'c,t,u,v,w,l,r').split(',')
+ end
+
+ def cookie(key, default)
+ @_cookie[key] || default
+ end
+
+ def parse_cookie
+ bib_settings = request.cookies['biberon_settings'] || ''
+ bib_settings.split(';').to_h do |cook|
+ cook.split(':').tap { |data| data[1] ||= '-' }
+ end
+ end
+ end
+end
diff --git a/lib/biberon/routes/users.rb b/lib/biberon/routes/users.rb
new file mode 100644
index 0000000..0b3e3de
--- /dev/null
+++ b/lib/biberon/routes/users.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Biberon
+ class App
+ route 'users' do |routing|
+ clear_session
+
+ routing.post 'login' do
+ user = User.authenticate(request.params)
+ if user.nil?
+ session['login_error'] = true
+ routing.redirect '/'
+ end
+ session['user_id'] = user.id
+ routing.redirect '/babies'
+ end
+
+ routing.get('logout') { routing.redirect '/' }
+
+ routing.post 'signup' do
+ routing.redirect '/' if CONFIG.get(:close_registration, false)
+ params = request.params
+ user = User.create(email: params['email'],
+ password: params['password'])
+ baby = Baby.create(name: params['name'], user: user)
+ session['user_id'] = user.id
+ routing.redirect "/babies/#{baby.id}"
+ end
+ end
+ end
+end
diff --git a/lib/tasks/admin.rake b/lib/tasks/admin.rake
index 43403fb..8521982 100644
--- a/lib/tasks/admin.rake
+++ b/lib/tasks/admin.rake
@@ -3,15 +3,23 @@
require 'securerandom'
namespace :admin do
+ desc 'Create user account'
+ task :new_user, [:email, :password] do |_, args|
+ pass = args.fetch(:password, '')
+ if pass == ''
+ pass = Biberon::User.generate_password
+ puts "Password for new user is: #{pass}"
+ end
+ Biberon::User.create(email: args[:email], password: pass)
+ end
+
desc 'Create a new baby'
- task :new_baby, [:name, :password] do |_, args|
- if !args[:password] || args[:password].empty?
- puts 'Please provide a password'
+ task :new_baby, [:name, :user_id] do |_, args|
+ if !args[:user_id] || args[:user_id].empty?
+ puts 'Please provide a user id'
next
end
- baby = Biberon::Baby.new(name: args[:name])
- baby.password = args[:password]
- baby.save_changes
+ Biberon::Baby.create(name: args[:name], user_id: args[:user_id])
end
desc 'Create a secure session secret'
diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake
index 525cf68..7b070ef 100644
--- a/lib/tasks/assets.rake
+++ b/lib/tasks/assets.rake
@@ -15,6 +15,15 @@ namespace :assets do
sh 'npm install'
end
+ file 'public/css/style.css' do
+ sh 'npm run build'
+ end
+
+ file 'public/css/iconoir.css' do
+ cp 'node_modules/iconoir/css/iconoir.css',
+ 'public/css/iconoir.css'
+ end
+
file 'public/css/milligram.min.css' do
cp 'node_modules/milligram/dist/milligram.min.css',
'public/css/milligram.min.css'
@@ -39,6 +48,7 @@ namespace :assets do
desc 'Compile or copy all assets'
task all: ['node_modules',
+ 'public/css/iconoir.css',
'public/css/milligram.min.css',
'public/js/chart.min.js',
'assets:compress']
diff --git a/lib/tasks/server.rake b/lib/tasks/server.rake
index 08b6eb7..bf9810f 100644
--- a/lib/tasks/server.rake
+++ b/lib/tasks/server.rake
@@ -1,43 +1,32 @@
# frozen_string_literal: true
-require 'rainbow'
-
namespace :server do
- desc 'Stop the server'
- task :stop do
- unless File.exist? 'tmp/puma.pid'
- warn Rainbow('No pid file found').red
- exit 1
- end
- pid = File.read('tmp/puma.pid').strip.to_i
- Process.kill('TERM', pid)
- File.unlink 'tmp/puma.pid'
- puts Rainbow('Done').green
- end
-
- desc 'Start the server'
+ desc 'Start the dev server'
task :start do
env = ENV.fetch 'RACK_ENV', 'development'
- cmd = ['rackup', '-s', 'puma',
- '-E', env, '-P', 'tmp/puma.pid']
- if env == 'development'
- # Ensure we don't keep cached/compressed version of the assets
- Rake::Task['assets:clean_compressed'].invoke
- Rake::Task['assets:all'].invoke
- else
- cmd << '-D'
+ # Ensure we don't keep cached/compressed version of the assets
+ Rake::Task['assets:clean_compressed'].invoke
+ srv = Thread.new do
+ sh ['rackup', '-s', 'puma', '-E', env].join(' ')
+ end
+ begin
+ # Start sass watch server
+ Rake::Task['server:sass'].invoke
+ rescue Interrupt
+ srv.exit
end
- sh cmd.join(' ')
end
- desc 'Restart the server'
- task restart: ['server:stop', 'server:start']
+ desc 'Start Sass watcher'
+ task :sass do
+ sh 'npm run watch'
+ end
desc 'Publish on upstream server'
task publish: ['assets:all'] do
cmd = ['rsync', '-rltv', '--delete', '.',
- ENV.fetch('BIBERON_PUBLISH_FOLDER')]
- exclude_files = ['.*', 'node_modules', 'package*.json', 'assets',
+ Biberon::CONFIG.get(:publish_folder)]
+ exclude_files = ['.*', 'node_modules', 'package*.json', 'config.yml',
'tmp*', 'db/biberon.db']
cmd.insert(3, *exclude_files.map { |f| "--exclude \"#{f}\"" })
sh cmd.join(' ')
diff --git a/locales/en.yml b/locales/en.yml
index bc657f5..c9636a0 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -1,13 +1,44 @@
---
+app_name: Biberon
home: Home
+mission_statement: Easily track your baby's nutrition over their first few weeks
+your_babies: Your babies
footer:
made_with_love: Made with ❤️ at %1
in_france: France
-session:
- name: Name
- password: Password
+ foss_note: |
+ "biberon" is a free and open source software, %1 !
+ repo_link: you can contribute and improve it
+user:
+ email_login: Email
+ email_signup: Email to log in
login: Log in
logout: Logout
+ password: Password
+ login_failed: Email and password do not match. Please try again.
+password:
+ title: Password change
+ link_forgotten: Forgotten password?
+ reset_email: |
+ Enter your email address in the field below to receive a message allowing
+ you to reset your password
+ reset_submit: Send reset email
+ reset_error: Something bad happen… Please, try again.
+ reset_mail_sent_notif: |
+ You should have received an email containing the instruction to let you
+ change your password.
+ password_changed_notif: |
+ Your password has been successfully changed. You can now log in below.
+ token_error: |
+ The used link is not valid. Please go back to your mail box and click on
+ the link of the last received message, or %1.
+ token_error_link: click here to send a new link
+ new_password: What is your new password?
+ confirm_password: Please confirm your new password
+baby:
+ add_a_baby: Add a baby
+ edit_name: Edit name
+ name: Name of your new baby
biberon:
hour: Hour
quantity: Quantity
@@ -29,7 +60,8 @@ biberon:
today: Today
show_graph: Show graph
validations:
- baby: is not set
+ baby: is not present
+ datetime: is not present
quantity: cannot be empty if temperature, weight, left or right
breast or comment are empty too
graph:
@@ -38,3 +70,28 @@ settings:
settings: Settings
language: Language
columns: Displayed columns
+emails:
+ password_change:
+ subject: Biberon - Password change request
+ body: |
+ Hi,
+
+ You just ask to change your password to log in the Biberon application.
+
+ If you did not do that, you can safely ignore this email.
+
+ If you did, just click on the link below to reset your password:
+
+ {{base_uri}}/password/change?token={{token}}
+ body_html: |
+ <p>Hi,</p>
+ <p>You just ask to change your password to log in the Biberon
+ application.</p>
+ <p>If you did not do that, you can safely ignore this email.</p>
+ <p>If you did, just click on the link below to reset your password:</p>
+ <p>
+ <a class="button is-primary"
+ href="{{base_uri}}/password/change?token={{token}}">
+ Change my password
+ </a>
+ </p>
diff --git a/locales/fr.yml b/locales/fr.yml
index 132e038..971b0fa 100644
--- a/locales/fr.yml
+++ b/locales/fr.yml
@@ -1,13 +1,45 @@
---
+app_name: Biberon
home: Accueil
+mission_statement: Suivez facilement la nutrition de votre bébé sur ses premières semaines
+your_babies: Vos bébés
footer:
made_with_love: Fait avec ❤️ à %1
in_france: Loire-Atlantique, France
-session:
- name: Nom
- password: Mot de passe
+ foss_note: « biberon » est un logiciel libre, %1 !
+ repo_link: vous pouvez y contribuer et l’améliorer
+user:
+ email_login: Email
+ email_signup: Email pour vous connecter
login: Se connecter
logout: Se déconnecter
+ password: Mot de passe
+ login_failed: L’email et le mot de passe ne correspondent pas. Merci de réessayer.
+password:
+ title: Changer de mot de passe
+ link_forgotten: Mot de passe oublié ?
+ reset_email: |
+ Entrez votre adresse email ci-dessous pour recevoir un message vous
+ permettant de changer de mot de passe
+ reset_submit: Envoyer l’email de changement
+ reset_error: Quelque chose s’est mal passé… Merci de réessayer.
+ reset_mail_sent_notif: |
+ Vous devriez avoir reçu un email contenant les instructions pour changer
+ votre mot de passe.
+ password_changed_notif: |
+ Votre changement de mot de passe a été pris en compte, vous pouvez
+ désormais vous connecter ci-dessous.
+ token_error: |
+ Le lien utilisé est invalide. Merci de bien vouloir consulter à nouveau
+ votre boîte mail et cliquer sur le lien contenu dans le dernier message
+ reçu, ou %1.
+ token_error_link: cliquer ici pour renvoyer un nouveau lien
+ new_password: Quel nouveau mot de passe voulez-vous utiliser ?
+ confirm_password: Confirmez votre nouveau mot de passe
+baby:
+ add_a_baby: Ajouter un bébé
+ edit_name: Modifier son nom
+ name: Nom de votre nouveau bébé
biberon:
hour: Heure
quantity: Quantité
@@ -30,6 +62,7 @@ biberon:
show_graph: Afficher le graphique
validations:
baby: n’est pas présent
+ datetime: n’est pas présent
quantity: ne peut pas être vide si Température, Poids, Sein gauche
ou droit ou Observations sont également vides
graph:
@@ -38,3 +71,33 @@ settings:
settings: Paramètres
language: Langue
columns: Colonnes affichées
+emails:
+ password_change:
+ subject: Biberon - Changement de mot de passe
+ body: |
+ Bonjour,
+
+ Vous venez de demander à changer votre mot de passe pour vous connecter
+ sur l’application Biberons.
+
+ Si vous n’êtes pas à l’origine de cette demande, vous pouvez tout à fait
+ ignorer cet email.
+
+ Dans le cas contraire, cliquez simplement sur le lien suivant pour
+ réinitialiser votre mot de passe :
+
+ {{base_uri}}/password/change?token={{token}}
+ body_html: |
+ <p>Bonjour,</p>
+ <p>Vous venez de demander à changer votre mot de passe pour vous
+ connecter sur l’application Biberons.</p>
+ <p>Si vous n’êtes pas à l’origine de cette demande, vous pouvez tout à
+ fait ignorer cet email.</p>
+ <p>Dans le cas contraire, cliquez simplement sur le lien suivant pour
+ réinitialiser votre mot de passe&nbsp;:</p>
+ <p>
+ <a class="button is-primary"
+ href="{{base_uri}}/password/change?token={{token}}">
+ Changer mon mot de passe
+ </a>
+ </p>
diff --git a/package-lock.json b/package-lock.json
index 8a8a341..e730da6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,48 +5,404 @@
"packages": {
"": {
"name": "biberon",
+ "devDependencies": {
+ "chart.js": "^3.9",
+ "iconoir": "^5.3",
+ "sass": "^1.55"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+ "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
"dependencies": {
- "chart.js": "^3.8",
- "milligram": "^1.4"
+ "fill-range": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=8"
}
},
"node_modules/chart.js": {
- "version": "3.8.0",
- "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.8.0.tgz",
- "integrity": "sha512-cr8xhrXjLIXVLOBZPkBZVF6NDeiVIrPLHcMhnON7UufudL+CNeRrD+wpYanswlm8NpudMdrt3CHoLMQMxJhHRg=="
+ "version": "3.9.1",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz",
+ "integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==",
+ "dev": true
+ },
+ "node_modules/chokidar": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+ "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ ],
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/iconoir": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/iconoir/-/iconoir-5.3.1.tgz",
+ "integrity": "sha512-G7wF7zQzruIIbx9yZdyBDprF0IW31GhjrSxphzMZGPAiHMkxeL/mEpjDoGtkozd1eySd4pE5gonG75EnQEc9Mg==",
+ "dev": true
},
- "node_modules/milligram": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/milligram/-/milligram-1.4.1.tgz",
- "integrity": "sha512-RCgh/boHhcXWOUfKJWm3RJRoUeaEguoipDg0mJ31G0tFfvcpWMUlO1Zlqqr12K4kAXfDlllaidu0x7PaL2PTFg==",
+ "node_modules/immutable": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
+ "integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
+ "dev": true
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
"dependencies": {
- "normalize.css": "~8.0.1"
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/sass": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.55.0.tgz",
+ "integrity": "sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A==",
+ "dev": true,
+ "dependencies": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
}
},
- "node_modules/normalize.css": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz",
- "integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg=="
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
}
},
"dependencies": {
+ "anymatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+ "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+ "dev": true,
+ "requires": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ }
+ },
+ "binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true
+ },
+ "braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "requires": {
+ "fill-range": "^7.0.1"
+ }
+ },
"chart.js": {
- "version": "3.8.0",
- "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.8.0.tgz",
- "integrity": "sha512-cr8xhrXjLIXVLOBZPkBZVF6NDeiVIrPLHcMhnON7UufudL+CNeRrD+wpYanswlm8NpudMdrt3CHoLMQMxJhHRg=="
+ "version": "3.9.1",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz",
+ "integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==",
+ "dev": true
+ },
+ "chokidar": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+ "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+ "dev": true,
+ "requires": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "fsevents": "~2.3.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ }
+ },
+ "fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
+ "fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "optional": true
+ },
+ "glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.1"
+ }
+ },
+ "iconoir": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/iconoir/-/iconoir-5.3.1.tgz",
+ "integrity": "sha512-G7wF7zQzruIIbx9yZdyBDprF0IW31GhjrSxphzMZGPAiHMkxeL/mEpjDoGtkozd1eySd4pE5gonG75EnQEc9Mg==",
+ "dev": true
+ },
+ "immutable": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
+ "integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
+ "dev": true
+ },
+ "is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "requires": {
+ "binary-extensions": "^2.0.0"
+ }
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true
},
- "milligram": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/milligram/-/milligram-1.4.1.tgz",
- "integrity": "sha512-RCgh/boHhcXWOUfKJWm3RJRoUeaEguoipDg0mJ31G0tFfvcpWMUlO1Zlqqr12K4kAXfDlllaidu0x7PaL2PTFg==",
+ "is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
"requires": {
- "normalize.css": "~8.0.1"
+ "is-extglob": "^2.1.1"
}
},
- "normalize.css": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz",
- "integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg=="
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true
+ },
+ "normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true
+ },
+ "picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true
+ },
+ "readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "requires": {
+ "picomatch": "^2.2.1"
+ }
+ },
+ "sass": {
+ "version": "1.55.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.55.0.tgz",
+ "integrity": "sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A==",
+ "dev": true,
+ "requires": {
+ "chokidar": ">=3.0.0 <4.0.0",
+ "immutable": "^4.0.0",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ }
+ },
+ "source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "dev": true
+ },
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^7.0.0"
+ }
}
}
}
diff --git a/package.json b/package.json
index 44a13e2..16e5e67 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,12 @@
{
"name": "biberon",
- "dependencies": {
- "chart.js": "^3.8",
- "milligram": "^1.4"
+ "scripts": {
+ "build": "sass -s compressed --no-source-map assets/style.sass public/css/style.css",
+ "watch": "sass -s compressed --no-source-map --watch assets/style.sass public/css/style.css"
+ },
+ "devDependencies": {
+ "chart.js": "^3.9",
+ "iconoir": "^5.3",
+ "sass": "^1.55"
}
}
diff --git a/public/css/style.css b/public/css/style.css
deleted file mode 100644
index 3f61f42..0000000
--- a/public/css/style.css
+++ /dev/null
@@ -1,38 +0,0 @@
-.center-text {
- text-align: center;
-}
-
-.geneston {
- text-decoration: underline dotted;
-}
-
-.errors {
- background: #ff3860;
- border: 1px solid #cc0f35;
- border-radius: .4rem;
- color: #ffffff;
- padding: 1rem;
-}
-
-ul.errors {
- list-style: none;
-}
-
-ul.errors li:last-child {
- margin-bottom: 0;
-}
-
-table.biblist form, table.biblist fieldset, table.biblist button {
- margin-bottom: 0;
-}
-
-canvas.chart {
- margin-bottom: 4rem
-}
-
-@media (max-width: 40.0rem) {
- .row .column.column-33 {
- max-width: 100%;
- flex: none;
- }
-}
diff --git a/public/img/favicon.png b/public/img/favicon.png
new file mode 100644
index 0000000..2b98a59
--- /dev/null
+++ b/public/img/favicon.png
Binary files differ
diff --git a/public/img/favicon_180.png b/public/img/favicon_180.png
new file mode 100644
index 0000000..4fbdf41
--- /dev/null
+++ b/public/img/favicon_180.png
Binary files differ
diff --git a/public/img/favicon_192.png b/public/img/favicon_192.png
new file mode 100644
index 0000000..2a195ba
--- /dev/null
+++ b/public/img/favicon_192.png
Binary files differ
diff --git a/public/img/undraw_baby_feeding.svg b/public/img/undraw_baby_feeding.svg
new file mode 100644
index 0000000..62e8dd1
--- /dev/null
+++ b/public/img/undraw_baby_feeding.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="401.99904" height="607.31955" viewBox="0 0 401.99904 607.31955" xmlns:xlink="http://www.w3.org/1999/xlink"><polygon points="263.59649 237.85227 256.28537 161.08553 167.33345 170.83368 167.33345 236.63375 263.59649 237.85227" fill="#ffb6b6"/><polygon points="297.10141 550.39957 297.10141 607.31955 295.18142 606.44956 63.15146 606.44956 69.85141 565.05961 70.98142 558.07956 71.87143 552.60959 114.13144 552.19956 114.17141 552.18955 131.04142 552.02958 135.33146 551.9896 138.68142 551.94956 189.3414 551.45957 258.40146 550.77958 297.10141 550.39957" fill="#2f2e41"/><polygon points="166.11493 218.35595 262.37799 226.88558 328.02782 293.20639 303.19839 452.92096 297.1058 568.68033 68.02409 565.02475 94.83152 429.76911 82.64633 245.77265 166.11493 218.35595" fill="#e6e6e6"/><path d="M283.63144,601.2996l-3.85004,5.14996h-99.06c-9.03998-8.96997-23.77997-21.19-39.71997-33.67999-2.16003-1.70996-4.34998-3.40997-6.54999-5.10999-.69-.53998-1.38-1.07001-2.07001-1.59998l-.01001-.01001c-3.60999-2.79004-7.23999-5.57001-10.84003-8.29999-2.46997-1.88-4.92999-3.74005-7.35999-5.56006-.01001-.00995-.01996-.01996-.03998-.02997-.78003-.59003-1.57001-1.17999-2.35004-1.76001-14.31995-10.73999-27.35999-20.21002-35.65997-26.21997h-.01001c-2.42999-1.75-4.45996-3.20001-5.97998-4.30005-2.14001-1.53998-3.33002-2.37994-3.33002-2.37994l2.45001-1.91003c.02002,.01001,.02002,0,.02002,0l10-7.72998,.09998-.08002,6.47998-5.01001,39.67004-30.69-1.65002-1.46997,32.89001,11.57996,36.25,20.93005,31.88,18.39996,10,5.78003c4.82001,2.77997,9.22998,6.15997,13.13995,10.03998,2.82001,2.79999,5.39001,5.85999,7.64001,9.14001,.89001,1.27002,1.72003,2.58002,2.51001,3.91998l.22003,.38,10.25995,17.45001,14.21002,32.38,.76001,.69Z" fill="#9b4dca"/><path d="M232.31712,606.45444h62.86339l-11.55154-5.15442-7.31113-3.26562c-15.45081-6.89679-32.91223-2.961-44.00073,8.41998l.00002,.00006Z" fill="#ffb6b6"/><circle cx="185.00198" cy="128.79476" r="81.64083" fill="#ffb6b6"/><path d="M100.31487,246.3819s-37.77411-7.31113-53.61486,28.02594c-15.84076,35.33707-25.58891,86.51489-25.58891,86.51489l85.29637,3.65555-6.0926-118.19641v.00003Z" fill="#e6e6e6"/><path d="M200.29142,606.44956H125.21145l-15.05005-14.26996-.03998,.02997,.25-.25,24.08002-24.29999,1.52997-1.54999,7-7.04999,.17999-.19,2.89001,3.02997,4.22998,4.44,.22003,.22998,23.12,24.24005c9.65002,2.79999,18.47998,7.87994,25.76001,14.78998,.31,.27997,.60999,.57001,.90997,.84998h.00002Z" fill="#ffb6b6"/><path d="M118.09708,548.4345l14.80314,13.6286c13.86811,7.87751,31.8871,12.10952,34.41912,12.67971l3.45308,1.27208c3.61142,4.36194,8.21519,7.79541,13.4259,10.01285,12.95672,5.72625,26.41383,3.68256,30.05672-4.56384v.00002c3.64289-8.24633-3.90763-19.5706-16.86974-25.29718-5.14643-2.35858-10.78291-3.45035-16.43777-3.18399l-.65368-.2791-40.12621-19.36003" fill="#ffb6b6"/><path d="M140.52601,483.99323c2.43704,0-38.99263,45.08524-38.99263,45.08524l35.90796,27.93481,12.83282-52.30518-9.74815-20.71484v-.00003Z" fill="#9b4dca"/><path d="M150.84684,566.34073l-12.78226-11.83179-4.44756-4.10638-17.81475-16.48663-9.57762-9.96747-13.62304-14.15918-6.75057-7.01868-4.55725-4.72787-4.1308-4.30133,1.66937-17.32739,3.44843-35.70261,4.42323-45.85291,3.44836-35.7392,.41433-4.28915-14.69527-21.22055-55,27-14.65062,109.22443-2.48583,11.77087v.00003c-2.05896,9.7388,1.35264,19.82571,8.90039,26.31528l50.51464,43.43274,6.48253,5.56866,9.62626,8.28595,31.6328,27.19739,39.95524-26.06415-.00002-.00006Z" fill="#e6e6e6"/><circle cx="66.19631" cy="453.53025" r="62.14451" fill="#ffb6b6"/><path d="M92.86054,463.66794l1.05061-19.01178-8.52964-14.62225s-2.43704-49.95932-21.93335-47.52228C43.95184,384.9487,1.30366,420.28577,.08514,433.68949c-1.21852,13.40372,10.7863,70.9201,26.80743,70.67413,11.55853-.17749,8.97886,27.24597,65.97886,6.24597,6.46799-2.38293-18.4566-17.21265-14.80104-25.74228,3.65556-8.52963,14.79014-21.19937,14.79014-21.19937h.00002Z" fill="#2f2e41"/><g><path d="M156.58283,281.54573s69.47862-68.63578,96.95683-56.36104c27.47821,12.27474,74.26616,70.35153,74.26616,70.35153l-67.47414,54.63991-103.74884-68.6304Z" fill="#e6e6e6"/><g><g><path d="M141.34538,422.7784s9.64396-1.87233,8.84291,3.51785c-.80105,5.39019-16.72309,27.26461-21.71274,23.76847-4.98965-3.49614-2.22942-14.65533-.29195-16.57109,1.93748-1.91576,13.16181-10.71523,13.16181-10.71523h-.00003Z" fill="#9b4dca"/><path d="M150.20814,424.18352c-8.50724,3.2583-18.76578,20.48767-20.37726,24.55132l49.19709,45.736c3.53713,2.42997,8.12007,2.47834,11.67444,.12339,13.53551-8.96662,19.38683-26.48935,21.6698-36.13656,1.09032-4.6078-1.09566-9.37797-5.31459-11.6024,0,0-56.84949-22.67175-56.84948-22.67174Z" fill="#9b4dca"/></g><ellipse cx="131.20804" cy="428.81994" rx="6.5" ry="5.5" transform="translate(-126.80874 62.0374) rotate(-18.11206)" fill="#9b4dca"/></g><path d="M248.78977,424.32847l-44.53469,10.17093c-9.78448-2.25026-19.96565-2.12445-29.69168,.36669-24.42886,5.90127-41.18102,23.27995-37.42724,38.82123,3.75382,15.54133,26.59323,23.3567,51.0126,17.45427,9.79364-2.22462,18.91188-6.76301,26.59162-13.23512l43.47751-7.64158-9.43252-45.97702,.0044,.04061Z" fill="#ffb6b6"/><path d="M321.59779,291.1442l-59.24502,46.26043,55.1724,62.3119-68.7398,24.57133,9.43252,45.97702c6.03202-.90138,17.90864-2.7932,32.46761-5.7065l27.18318-6.04876c27.67392-6.83638,57.75409-16.56684,75.60792-29.34979,39.64259-28.38334-71.87882-138.01562-71.87882-138.01562Z" fill="#e6e6e6"/></g><path d="M152.58623,24.38528C168.73249,18.71054,178.96405-.27711,192.8904,.00307c30.24932,.6095,58.7785,22.21598,62.31261,25.39464,14.715,13.23395,20.33685,27.82989,26.81267,44.64417,6.85799,17.80521,5.08862,45.58469,2.56673,65.00826-3.68807,28.4011-22.08604,36.08384-17.65547,54.08386,3.10139,12.59949,13.99725,16.48203,26.98871,27.27118,24.57237,20.40773,33.73954,46.50581,43.09677,73.14342,22.29607,63.47364,8.51045,131.62998-.00415,132.31554-1.79925,.14498-3.83168-3.93993-7.89644-12.1087-12.45175-25.02435-8.72248-33.545-15.61684-41.59039-12.3329-14.39161-33.24243,2.39547-59.76979-10.49587-20.89887-10.15631-30.04667-31.32635-35.37636-43.66173-3.06415-7.09098-15.98501-36.99389-5.71865-68.75512,9.50885-29.41838,27.86377-32.79024,37.59928-67.24007,2.0524-7.26279,9.37881-26.33363-2.04273-38.78672-5.78881-6.31133-.31481,32.60737-8.52982,27.07852-19.97594-13.44382-19.16773-77.75567-38.11196-93.30795-9.06463-7.4416-21.70584-9.29692-31.20097-7.26605-22.54013,4.82168-60.50052,31.64352-61.26427,56.48619-1.22947,39.9813,13.76695,55.64267,48.9058,81.58486,3.79688,2.80314-9.37576,2.57746-14.98646,1.71928-28.5694-4.37043-43.49514-38.95346-46.5722-46.71552-9.01659-22.74519-6.46448-42.65308-4.49059-58.05051,2.52294-19.68212,5.23859-40.86489,21.6917-56.716,9.74087-9.38401,20.37472-13.1215,38.95824-19.65307Z" fill="#2f2e41"/></svg> \ No newline at end of file
diff --git a/public/js/edit_baby.js b/public/js/edit_baby.js
new file mode 100644
index 0000000..29f378a
--- /dev/null
+++ b/public/js/edit_baby.js
@@ -0,0 +1,38 @@
+for (let source of document.querySelectorAll(".edit-toggle")) {
+ source.addEventListener("click", function(event) {
+ event.preventDefault();
+ let target = event.target;
+ while (!target.classList.contains("edit-toggle")) {
+ target = target.parentElement;
+ }
+ if (!target.id) return false;
+ const edit_form = document.getElementById(target.id + "-target");
+ if (!edit_form) return false;
+ target.style.display = "none";
+ edit_form.style.display = "block";
+ });
+}
+
+function closeToggleTarget(target) {
+ const target_id = target.id;
+ if (!target_id) return false;
+ const source = document.getElementById(target_id.replace("-target", ""));
+ if (!source) return false;
+ target.style.display = "none";
+ source.style.display = "block";
+}
+
+for (const source of document.querySelectorAll(".edit-target button[type='reset']")) {
+ source.addEventListener("click", function(event) {
+ closeToggleTarget(event.target.form);
+ });
+}
+
+document.addEventListener("keyup", function(event) {
+ if (!event.key) return false;
+ if (event.key != "Esc" && event.key != "Escape") return false;
+ event.preventDefault();
+ for (const target of document.querySelectorAll(".edit-target")) {
+ closeToggleTarget(target);
+ }
+});
diff --git a/spec/babies_spec.rb b/spec/babies_spec.rb
new file mode 100644
index 0000000..216d053
--- /dev/null
+++ b/spec/babies_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+describe Biberon::Baby do
+ let(:user) do
+ Biberon::User.create(email: 'test@test.com', password: 'test')
+ end
+
+ context 'While creating a baby' do
+ it 'creates a baby with a name and a user' do
+ baby = described_class.create(name: 'Test', user: user)
+ expect(baby).to be_an_instance_of(described_class)
+ expect(baby.name).to eq('Test')
+ end
+
+ it 'creates a baby with a name and a user_id' do
+ baby = described_class.create(name: 'Test', user_id: user.id)
+ expect(baby).to be_an_instance_of(described_class)
+ expect(baby.name).to eq('Test')
+ end
+
+ it 'does not create a baby without a user' do
+ expect do
+ described_class.create(name: 'Test')
+ end.to raise_error(Sequel::ValidationFailed, 'user is not present')
+ end
+ end
+
+ context 'With an existing baby' do
+ let(:baby) { described_class.create(name: 'Test', user: user) }
+
+ before(:context) do
+ Biberon::Biberon.unrestrict_primary_key
+ end
+
+ after(:context) do
+ Biberon::Biberon.restrict_primary_key
+ end
+
+ it 'computes their birthday without biberons' do
+ datetime = DateTime.new(2022, 4, 12, 1, 30)
+ expect(baby.age(datetime)).to eq(0)
+ end
+
+ it 'computes their birthday' do
+ first_day = DateTime.new(2022, 4, 12, 1, 30)
+ other_day = DateTime.new(2022, 5, 24, 12, 42)
+ Biberon::Biberon.create(comment: 'Test', baby: baby, datetime: first_day)
+ expect(baby.age(other_day)).to eq(42)
+ end
+
+ it 'computes the total quantity of a given day with no meal' do
+ expect(baby.total_bibs_of_day(Date.new(2022, 5, 24))).to eq(0)
+ end
+
+ it 'returns no statistics for new born baby' do
+ expect(baby.statistics).to be_an_instance_of(Array)
+ expect(baby.statistics).to be_empty
+ end
+
+ context 'With some biberons' do
+ before(:each) do
+ [{ hour: 9, minute: 42, quantity: 90 },
+ { hour: 12, minute: 42, quantity: 60 },
+ { hour: 15, minute: 42, quantity: 120 }].each do |spec|
+ datetime = DateTime.new(2022, 5, 24, spec[:hour], spec[:minute])
+ Biberon::Biberon.create(quantity: spec[:quantity],
+ baby: baby, datetime: datetime)
+ end
+ end
+
+ it 'computes the total quantity of a given day' do
+ expect(baby.total_bibs_of_day(Date.new(2022, 5, 24))).to eq(270)
+ end
+
+ it 'computes some statistics' do
+ allow(Date).to receive(:today).and_return(Date.new(2022, 5, 24))
+ stats = baby.statistics
+ expect(stats.length).to eq(1)
+ item = stats[0]
+ expect(item).to include('day' => '20220524',
+ 'bibs' => 3, 'feces' => 0,
+ 'total_drinked' => 270)
+ end
+ end
+ end
+end
diff --git a/spec/biberons_spec.rb b/spec/biberons_spec.rb
new file mode 100644
index 0000000..9d064c3
--- /dev/null
+++ b/spec/biberons_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'date'
+
+describe Biberon::Biberon do
+ before(:context) do
+ Biberon::Biberon.unrestrict_primary_key
+ end
+
+ after(:context) do
+ Biberon::Biberon.restrict_primary_key
+ end
+
+ context 'While creating a biberon' do
+ let(:baby) do
+ user = Biberon::User.create(email: 'test@test.com', password: 'test')
+ Biberon::Baby.create(name: 'Test', user: user)
+ end
+
+ let(:now) { DateTime.now }
+
+ it 'creates a biberon for a baby with quantity and a datetime' do
+ bib = described_class.create(quantity: 90, baby: baby, datetime: now)
+ expect(bib).to be_an_instance_of(described_class)
+ expect(bib.baby.name).to eq('Test')
+ expect(bib.quantity).to eq(90)
+ end
+
+ it 'creates a biberon for a baby with a comment and a datetime' do
+ bib = described_class.create(comment: 'Is fine', baby: baby, datetime: now)
+ expect(bib).to be_an_instance_of(described_class)
+ expect(bib.baby.name).to eq('Test')
+ expect(bib.comment).to eq('Is fine')
+ end
+
+ it 'does not create a biberon with just a baby and a datetime' do
+ expect do
+ described_class.create(baby: baby, datetime: now)
+ end.to raise_error(
+ Sequel::ValidationFailed,
+ 'quantity cannot be empty if temperature, weight, left or right breast or comment are empty too'
+ )
+ end
+
+ it 'does not create a biberon without a baby' do
+ expect do
+ described_class.create(quantity: 90, datetime: now)
+ end.to raise_error(Sequel::ValidationFailed, 'baby is not present')
+ end
+
+ it 'does not create a biberon without a datetime' do
+ expect do
+ described_class.create(quantity: 90, baby: baby)
+ end.to raise_error(Sequel::ValidationFailed, 'datetime is not present')
+ end
+
+ it 'does not create a biberon with an empty comment' do
+ expect do
+ described_class.create(comment: '', baby: baby, datetime: now)
+ end.to raise_error(
+ Sequel::ValidationFailed,
+ 'quantity cannot be empty if temperature, weight, left or right breast or comment are empty too'
+ )
+ end
+ end
+
+ context 'With a biberon' do
+ let(:biberon) do
+ user = Biberon::User.create(email: 'test@test.com', password: 'test')
+ baby = Biberon::Baby.create(name: 'Test', user: user)
+ datetime = DateTime.new(2022, 5, 24, 12, 42)
+ Biberon::Biberon.create(comment: 'Test', baby: baby, datetime: datetime)
+ end
+
+ it 'returns the meal date' do
+ expect(biberon.date).to eq(Date.new(2022, 5, 24))
+ end
+
+ it 'returns the meal hour as a string' do
+ expect(biberon.hour).to eq('12:42')
+ end
+ end
+end
diff --git a/spec/requirements.rb b/spec/requirements.rb
new file mode 100644
index 0000000..9ffe500
--- /dev/null
+++ b/spec/requirements.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'simplecov'
+
+require 'r18n-core'
+R18n.default_places = File.expand_path('../locales', __dir__)
+R18n.set 'en'
+
+# Force env to be test
+ENV['RACK_ENV'] = 'test'
+
+# Init config (to get db config, mainly)
+require 'biberon/config'
+
+ordered_tables = [:biberons, :babies, :users]
+
+require 'sequel/core'
+Sequel.extension :migration
+Sequel.connect(Biberon::CONFIG.get(:db_connect_uri)) do |db|
+ # Ensure migrations are up to date
+ Sequel::Migrator.run(db, 'db/migrations')
+ # Ensure test db is empty
+ ordered_tables.each do |table|
+ db.from(table).truncate
+ end
+end
+
+SimpleCov.start do
+ # Activate new covering precision
+ enable_coverage :branch
+
+ # Remove dev only tools from coverage check
+ add_filter ['lib/tasks/server.rake']
+ # Sort coverage results into usefull groups
+ add_group 'Core libs', 'lib/biberon'
+ add_group 'Rake tasks', 'lib/tasks'
+ add_group 'Specs', 'spec'
+end
+
+require 'biberon/models'
+
+RSpec.configure do |config|
+ config.after(:each) do
+ ordered_tables.each { |table| Biberon::DB.from(table).truncate }
+ end
+end
diff --git a/spec/users_spec.rb b/spec/users_spec.rb
new file mode 100644
index 0000000..2386dd6
--- /dev/null
+++ b/spec/users_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+describe Biberon::User do
+ context 'While creating a user' do
+ it 'creates a user with an email and a password' do
+ user = described_class.create(email: 'test@test.com', password: 'test')
+ expect(user).to be_an_instance_of(described_class)
+ expect(user.email).to eq('test@test.com')
+ end
+
+ it 'does not create a user without a password' do
+ expect do
+ described_class.create(email: 'test@test.com')
+ end.to raise_error(Sequel::ValidationFailed, 'password_hash is not present')
+ end
+
+ it 'does not create a user without an email' do
+ expect do
+ described_class.create(password: 'test')
+ end.to raise_error(Sequel::ValidationFailed, 'email is not present')
+ end
+ end
+
+ context 'With a valid user' do
+ it 'validates the correct password' do
+ user = described_class.create(email: 'test@test.com', password: 'test')
+ expect(user.check_password('test')).to be(true)
+ end
+
+ it 'rejects a wrong password' do
+ user = described_class.create(email: 'test@test.com', password: 'test')
+ expect(user.check_password('wrong')).to be(false)
+ end
+
+ it 'authenticates the right user' do
+ described_class.create(email: 'test@test.com', password: 'test')
+ user = described_class.authenticate(
+ 'email' => 'test@test.com', 'password' => 'test'
+ )
+ expect(user).to be_an_instance_of(described_class)
+ expect(user.email).to eq('test@test.com')
+ end
+
+ it 'does not authenticate a user with bad data' do
+ described_class.create(email: 'test@test.com', password: 'test')
+ [{}, { 'email' => 'wrong@wrong.com' },
+ { 'email' => 'wrong@wrong.com', 'password' => 'wrong' },
+ { 'email' => 'test@test.com', 'password' => 'wrong' }].each do |spec|
+ user = described_class.authenticate(spec)
+ expect(user).to be_nil
+ end
+ end
+ end
+
+ it 'generates a password' do
+ expect(described_class.generate_password).to match(/\A[A-Za-z0-9]{25}\z/)
+ end
+end
diff --git a/views/baby.slim b/views/babies/baby.slim
index 9e5e6f9..5cbc655 100644
--- a/views/baby.slim
+++ b/views/babies/baby.slim
@@ -1,4 +1,16 @@
-h1 = @baby.name
+h1#baby-name.edit-toggle title=R18n.t.baby.edit_name
+ = @baby.name
+ i.iconoir.iconoir-edit-pencil<
+
+form#baby-name-target.edit-target method='post' action="/babies/#{@baby.id}"
+ fieldset
+ == csrf_tag "/babies/#{@baby.id}"
+
+ label for='name' = R18n.t.baby.name
+ input#name type='text' name='name' required=true value=@baby.name
+
+ button.button-primary type='submit' = R18n.t.save
+ button.button-outline< type='reset' = R18n.t.cancel
- baby_age = @baby.age(@today)
- today_short = @today.strftime('%Y%m%d')
@@ -9,11 +21,11 @@ h2
p
- if baby_age > 0
- a.button> href="/#{@baby.id}/#{(@today - 1).strftime('%Y%m%d')}"
+ a.button> href="/babies/#{@baby.id}/#{(@today - 1).strftime('%Y%m%d')}"
| ← #{R18n.t.biberon.yesterday}
- a.button> href="/#{@baby.id}" = R18n.t.biberon.today
+ a.button> href="/babies/#{@baby.id}" = R18n.t.biberon.today
- if @today < Date.today
- a.button< href="/#{@baby.id}/#{(@today + 1).strftime('%Y%m%d')}"
+ a.button< href="/babies/#{@baby.id}/#{(@today + 1).strftime('%Y%m%d')}"
| #{R18n.t.biberon.tomorrow} →
table.biblist
@@ -68,9 +80,9 @@ table.biblist
- if @settings.include?('c')
td = bib.comment
td
- form action="/#{@baby.id}/delete" method='post'
+ form action='/biberons/delete' method='post'
fieldset
- == csrf_tag "/#{@baby.id}/delete"
+ == csrf_tag '/biberons/delete'
input type='hidden' name='date' value=today_short
input type='hidden' name='time' value=bib.hour
button.button-outline[type='submit'
@@ -84,14 +96,16 @@ table.biblist
th colspan='8' = @baby.total_bibs_of_day(@today)
p.center-text
- a.button.button-outline href="/#{@baby.id}/graph" = R18n.t.biberon.show_graph
+ a.button.button-outline href="/babies/#{@baby.id}/graph"
+ = R18n.t.biberon.show_graph
+ i.iconoir.iconoir-graph-up<
-form action="/#{@baby.id}/add" method='post'
+form action='/biberons/add' method='post'
fieldset
- == csrf_tag "/#{@baby.id}/add"
+ == csrf_tag '/biberons/add'
- if @errors&.any?
- ul.errors
+ ul.notification.is-danger
- @errors.each do |field, err|
- err.each do |message|
li
@@ -156,3 +170,5 @@ form action="/#{@baby.id}/add" method='post'
input.button-primary type='submit' value=R18n.t.save
input.button-outline< type='reset' value=R18n.t.cancel
+
+script type='text/javascript' src='/js/edit_baby.js'
diff --git a/views/graph.slim b/views/babies/graph.slim
index b15d21f..b326cd9 100644
--- a/views/graph.slim
+++ b/views/babies/graph.slim
@@ -5,7 +5,7 @@ canvas#bibs.chart
canvas#poops.chart
p.center-text
- a.button.button-outline href="/#{@baby.id}" = R18n.t.graph.back_to_bibs
+ a.button.button-outline href="/babies/#{@baby.id}" = R18n.t.graph.back_to_bibs
script type='text/javascript' src='/js/chart.min.js'
script type='text/javascript' src='/js/plot.js'
diff --git a/views/babies/index.slim b/views/babies/index.slim
new file mode 100644
index 0000000..1b78414
--- /dev/null
+++ b/views/babies/index.slim
@@ -0,0 +1,17 @@
+h1 = @title
+
+ul
+ - @user.babies.each do |baby|
+ li
+ a href="/babies/#{baby.id}" = baby.name
+
+form action='/babies' method='post'
+ fieldset
+ == csrf_tag '/babies'
+ legend = R18n.t.baby.add_a_baby
+
+ label for='name' = R18n.t.baby.name
+ input#name type='text' name='name' required=true
+
+ input.button-primary type='submit' value=R18n.t.baby.add_a_baby
+ input.button-outline< type='reset' value=R18n.t.cancel
diff --git a/views/index.slim b/views/index.slim
index a76a297..2c019e3 100644
--- a/views/index.slim
+++ b/views/index.slim
@@ -1,12 +1,53 @@
-form action='/session/create' method='post'
- fieldset
- == csrf_tag '/session/create'
+.home-hero.center-text
+ h1 = R18n.t.app_name
- label for='name' = R18n.t.session.name
- input#name type='text' name='name' required=true
+ h2 = R18n.t.mission_statement
- label for='password' = R18n.t.session.password
- input#password type='password' name='password' required=true
+.row.decorated
+ .column.column-33.column-offset-15
+ - if session['password_change']
+ .notification.is-success
+ - if session['password_change'] == 'token_sent'
+ = R18n.t.password.reset_mail_sent_notif
+ - elsif session['password_change'] == 'reset'
+ = R18n.t.password.password_changed_notif
+ - session.delete 'password_change'
- input.button-primary type='submit' value=R18n.t.session.login
- input.button-outline< type='reset' value=R18n.t.cancel
+ - if session['login_error']
+ .notification.is-danger = R18n.t.user.login_failed
+ - session.delete 'login_error'
+
+
+ form action='/users/login' method='post'
+ fieldset
+ == csrf_tag '/users/login'
+ legend = R18n.t.user.login
+
+ label for='email' = R18n.t.user.email_login
+ input#name type='email' name='email' required=true
+
+ label for='password' = R18n.t.user.password
+ input#password type='password' name='password' required=true
+
+ input.button-primary type='submit' value=R18n.t.user.login
+ input.button-outline< type='reset' value=R18n.t.cancel
+
+ p: a href='/password/new' = R18n.t.password.link_forgotten
+
+ - unless Biberon::CONFIG.get(:close_registration, false)
+ form.column.column-33 action='/users/signup' method='post'
+ fieldset
+ == csrf_tag '/users/signup'
+ legend = R18n.t.baby.add_a_baby
+
+ label for='name' = R18n.t.baby.name
+ input#name type='text' name='name' required=true
+
+ label for='email' = R18n.t.user.email_signup
+ input#email type='email' name='email' required=true
+
+ label for='password' = R18n.t.user.password
+ input#password type='password' name='password' required=true
+
+ input.button-primary type='submit' value=R18n.t.baby.add_a_baby
+ input.button-outline< type='reset' value=R18n.t.cancel
diff --git a/views/layout.slim b/views/layout.slim
index e0a24dc..614a0c6 100644
--- a/views/layout.slim
+++ b/views/layout.slim
@@ -5,25 +5,38 @@ html
meta http-equiv='Content-Type' content='text/html; charset=utf-8'
meta name='viewport' content='width=device-width, initial-scale=1'
title Biberons - #{@title}
+ link rel='icon' type='image/png' sizes='32x32' href='/img/favicon.png'
+ /! Android devices
+ link rel='icon' type='image/png' sizes='192x192' href='/img/favicon_192.png'
+ /! Apple devices
+ link rel='apple-touch-icon' type='image/png' sizes='180x180' href='/img/favicon_180.png'
link rel='stylesheet' href='/css/milligram.min.css' type='text/css'
+ link rel='stylesheet' href='/css/iconoir.css' type='text/css'
link rel='stylesheet' href='/css/style.css' type='text/css'
+
body
.container
== yield
- footer
- - inner_span = "<span class=\"geneston\" title=\"#{R18n.t.footer.in_france}\">Geneston</span>"
- p.center-text == R18n.t.footer.made_with_love(inner_span)
- p.center-text
+ nav.center-text
+ ul
- if request.path == '/settings'
- if session['baby_id']
- a> href="/#{session['baby_id']}" = R18n.t.graph.back_to_bibs
- - else
- a> href='/' = R18n.t.home
+ li: a href="/babies/#{session['baby_id']}" = R18n.t.graph.back_to_bibs
+ - elsif @user.nil?
+ li: a href='/' = R18n.t.home
- else
- a> href='/settings' = R18n.t.settings.settings
- - if session['baby_id']
- ' -
- a href='/session/logout' = R18n.t.session.logout
+ li: a href='/settings' = R18n.t.settings.settings
+ - if @user
+ - if request.path != '/babies'
+ li: a href='/babies' = R18n.t.your_babies
+ li: a href='/users/logout' = R18n.t.user.logout
+
+ footer
+ - inner_span = "<span class=\"geneston\" title=\"#{R18n.t.footer.in_france}\">Geneston</span>"
+ p == R18n.t.footer.made_with_love(inner_span)
+
+ - repo_link = "<a href=\"https://git.umaneti.net/biberons/about/\">#{R18n.t.footer.repo_link}</a>"
+ p == R18n.t.footer.foss_note(repo_link)
diff --git a/views/password/change.slim b/views/password/change.slim
new file mode 100644
index 0000000..2eb1c2f
--- /dev/null
+++ b/views/password/change.slim
@@ -0,0 +1,24 @@
+form action='/password/change' method='post'
+ fieldset
+ == csrf_tag '/password/change'
+
+ - unless @token
+ .notification.is-danger
+ ruby:
+ token_link = format(
+ '<a href="/password/new">%<label>s</a>',
+ label: R18n.t.password.token_error_link
+ )
+ == R18n.t.password.token_error(token_link)
+
+ legend = @title
+
+ input type='hidden' name='token' value=@token
+
+ label> for='password1' = R18n.t.password.new_password
+ input#password1.input type='password' name='password' required=true
+
+ label> for='password2' = R18n.t.password.confirm_password
+ input#password2.input type='password' name='confirm-password' required=true
+
+ input.button-primary type='submit' value=@title
diff --git a/views/password/new.slim b/views/password/new.slim
new file mode 100644
index 0000000..2f911e9
--- /dev/null
+++ b/views/password/new.slim
@@ -0,0 +1,16 @@
+- if session['password_change'] == 'error'
+ .notification.is-danger = R18n.t.password.reset_error
+ - session.delete 'password_change'
+
+form action='/password/new' method='post'
+ fieldset
+ == csrf_tag '/password/new'
+
+ legend = @title
+
+ label> for='email' = R18n.t.password.reset_email
+ input#email type='email' name='email'
+
+ button.button-primary type='submit'
+ = R18n.t.password.reset_submit
+ i.iconoir.iconoir-send-mail<
diff --git a/views/settings.slim b/views/settings.slim
index 86ad3d6..2ddde6c 100644
--- a/views/settings.slim
+++ b/views/settings.slim
@@ -8,7 +8,7 @@ form action='/settings' method='post'
option value='fr' selected=(lang == 'fr') Français
option value='en' selected=(lang == 'en') English
- - if session['baby_id']
+ - if @user
fieldset
legend = R18n.t.settings.columns