initial commit
This commit is contained in:
commit
7816f24695
4
.cargo/config.toml
Normal file
4
.cargo/config.toml
Normal file
@ -0,0 +1,4 @@
|
||||
[alias]
|
||||
loco = "run --"
|
||||
loco-tool = "run --bin tool --"
|
||||
playground = "run --example playground"
|
102
.github/workflows/ci.yaml
vendored
Normal file
102
.github/workflows/ci.yaml
vendored
Normal file
@ -0,0 +1,102 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
RUST_TOOLCHAIN: stable
|
||||
TOOLCHAIN_PROFILE: minimal
|
||||
|
||||
jobs:
|
||||
rustfmt:
|
||||
name: Check Style
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
components: rustfmt
|
||||
- name: Run cargo fmt
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
|
||||
clippy:
|
||||
name: Run Clippy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Run cargo clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms
|
||||
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_DB: postgres_test
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
# Set health checks to wait until postgres has started
|
||||
options: --health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Run cargo test
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --all-features --all
|
||||
env:
|
||||
REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}}
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test
|
||||
|
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
**/config/local.yaml
|
||||
**/config/*.local.yaml
|
||||
**/config/production.yaml
|
||||
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
|
||||
# include cargo lock
|
||||
!Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
*.sqlite
|
||||
.direnv/
|
2
.rustfmt.toml
Normal file
2
.rustfmt.toml
Normal file
@ -0,0 +1,2 @@
|
||||
max_width = 100
|
||||
use_small_heuristics = "Default"
|
5493
Cargo.lock
generated
Normal file
5493
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
Cargo.toml
Normal file
58
Cargo.toml
Normal file
@ -0,0 +1,58 @@
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "daily_aujourdhui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
default-run = "daily_aujourdhui-cli"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[workspace.dependencies]
|
||||
loco-rs = { version = "0.12.0" }
|
||||
|
||||
[dependencies]
|
||||
|
||||
loco-rs = { workspace = true }
|
||||
migration = { path = "migration" }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1.33.0", default-features = false }
|
||||
async-trait = "0.1.74"
|
||||
tracing = "0.1.40"
|
||||
chrono = "0.4"
|
||||
validator = { version = "0.18" }
|
||||
sea-orm = { version = "1.1.0", features = [
|
||||
"sqlx-sqlite",
|
||||
"sqlx-postgres",
|
||||
"runtime-tokio-rustls",
|
||||
"macros",
|
||||
] }
|
||||
|
||||
axum = "0.7.5"
|
||||
include_dir = "0.7"
|
||||
uuid = { version = "1.6.0", features = ["v4"] }
|
||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] }
|
||||
|
||||
# view engine i18n
|
||||
fluent-templates = { version = "0.8.0", features = ["tera"] }
|
||||
unic-langid = "0.9.4"
|
||||
# /view engine
|
||||
|
||||
[[bin]]
|
||||
name = "daily_aujourdhui-cli"
|
||||
path = "src/bin/main.rs"
|
||||
required-features = []
|
||||
|
||||
[[bin]]
|
||||
name = "tool"
|
||||
path = "src/bin/tool.rs"
|
||||
required-features = []
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3.1.1"
|
||||
rstest = "0.21.0"
|
||||
loco-rs = { workspace = true, features = ["testing"] }
|
||||
insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] }
|
58
README.md
Normal file
58
README.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Welcome to Loco :train:
|
||||
|
||||
[Loco](https://loco.rs) is a web and API framework running on Rust.
|
||||
|
||||
This is the **SaaS starter** which includes a `User` model and authentication based on JWT.
|
||||
It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server.
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
cargo loco start
|
||||
```
|
||||
|
||||
```sh
|
||||
$ cargo loco start
|
||||
Finished dev [unoptimized + debuginfo] target(s) in 21.63s
|
||||
Running `target/debug/myapp start`
|
||||
|
||||
:
|
||||
:
|
||||
:
|
||||
|
||||
controller/app_routes.rs:203: [Middleware] Adding log trace id
|
||||
|
||||
▄ ▀
|
||||
▀ ▄
|
||||
▄ ▀ ▄ ▄ ▄▀
|
||||
▄ ▀▄▄
|
||||
▄ ▀ ▀ ▀▄▀█▄
|
||||
▀█▄
|
||||
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
|
||||
██████ █████ ███ █████ ███ █████ ███ ▀█
|
||||
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
|
||||
██████ █████ ███ █████ █████ ███ ████▄
|
||||
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
|
||||
██████ █████ ███ ████ ███ █████ ███ ████▀
|
||||
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
https://loco.rs
|
||||
|
||||
environment: development
|
||||
database: automigrate
|
||||
logger: debug
|
||||
compilation: debug
|
||||
modes: server
|
||||
|
||||
listening on http://localhost:5150
|
||||
```
|
||||
|
||||
## Full Stack Serving
|
||||
|
||||
You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections.
|
||||
|
||||
|
||||
## Getting help
|
||||
|
||||
Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/).
|
4
assets/i18n/de-DE/main.ftl
Normal file
4
assets/i18n/de-DE/main.ftl
Normal file
@ -0,0 +1,4 @@
|
||||
hello-world = Hallo Welt!
|
||||
greeting = Hallochen { $name }!
|
||||
.placeholder = Hallo Freund!
|
||||
about = Uber
|
10
assets/i18n/en-US/main.ftl
Normal file
10
assets/i18n/en-US/main.ftl
Normal file
@ -0,0 +1,10 @@
|
||||
hello-world = Hello World!
|
||||
greeting = Hello { $name }!
|
||||
.placeholder = Hello Friend!
|
||||
about = About
|
||||
simple = simple text
|
||||
reference = simple text with a reference: { -something }
|
||||
parameter = text with a { $param }
|
||||
parameter2 = text one { $param } second { $multi-word-param }
|
||||
email = text with an EMAIL("example@example.org")
|
||||
fallback = this should fall back
|
1
assets/i18n/shared.ftl
Normal file
1
assets/i18n/shared.ftl
Normal file
@ -0,0 +1 @@
|
||||
-something = foo
|
3
assets/static/404.html
Normal file
3
assets/static/404.html
Normal file
@ -0,0 +1,3 @@
|
||||
<html><body>
|
||||
not found :-(
|
||||
</body></html>
|
BIN
assets/static/image.png
Normal file
BIN
assets/static/image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 298 KiB |
12
assets/views/home/hello.html
Normal file
12
assets/views/home/hello.html
Normal file
@ -0,0 +1,12 @@
|
||||
<html><body>
|
||||
<img src="/static/image.png" width="200"/>
|
||||
<br/>
|
||||
find this tera template at <code>assets/views/home/hello.html</code>:
|
||||
<br/>
|
||||
<br/>
|
||||
{{ t(key="hello-world", lang="en-US") }},
|
||||
<br/>
|
||||
{{ t(key="hello-world", lang="de-DE") }}
|
||||
|
||||
</body></html>
|
||||
|
138
config/development.yaml
Normal file
138
config/development.yaml
Normal file
@ -0,0 +1,138 @@
|
||||
# Loco configuration file documentation
|
||||
|
||||
# Application logging configuration
|
||||
logger:
|
||||
# Enable or disable logging.
|
||||
enable: true
|
||||
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
|
||||
pretty_backtrace: true
|
||||
# Log level, options: trace, debug, info, warn or error.
|
||||
level: debug
|
||||
# Define the logging format. options: compact, pretty or json
|
||||
format: compact
|
||||
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
|
||||
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
|
||||
# override_filter: trace
|
||||
|
||||
# Web server configuration
|
||||
server:
|
||||
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
|
||||
port: 5150
|
||||
# The UI hostname or IP address that mailers will point to.
|
||||
host: http://localhost
|
||||
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
|
||||
middlewares:
|
||||
# #############################################
|
||||
# Full stack SaaS asset serving
|
||||
# #############################################
|
||||
#
|
||||
# You can choose from:
|
||||
#
|
||||
# (1) Server-side rendered templates (like erb in Ruby, ejs in nodejs) based on the Tera templating engine.
|
||||
# (2) Client-side frontend set up
|
||||
#
|
||||
# You can also choose both.
|
||||
#
|
||||
# Uncomment the relevant sections below:
|
||||
|
||||
#
|
||||
# (1) Server-side static assets config
|
||||
# ====================================
|
||||
#
|
||||
# for use with the view_engine in initializers/view_engine.rs
|
||||
# (server-block-start)
|
||||
# static:
|
||||
# enable: true
|
||||
# must_exist: true
|
||||
# precompressed: false
|
||||
# folder:
|
||||
# uri: "/static"
|
||||
# path: "assets/static"
|
||||
# fallback: "assets/static/404.html"
|
||||
# (server-block-end)
|
||||
|
||||
#
|
||||
# (2) Client side app static config
|
||||
# =================================
|
||||
#
|
||||
# Note that you need to go in `frontend` and run your frontend build first,
|
||||
# e.g.: $ npm install & npm build
|
||||
#
|
||||
static:
|
||||
enable: true
|
||||
must_exist: true
|
||||
precompressed: false
|
||||
folder:
|
||||
uri: "/"
|
||||
path: "frontend/dist"
|
||||
fallback: "frontend/dist/index.html"
|
||||
#
|
||||
|
||||
# Worker Configuration
|
||||
workers:
|
||||
# specifies the worker mode. Options:
|
||||
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
|
||||
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
|
||||
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
|
||||
mode: BackgroundAsync
|
||||
|
||||
# Mailer Configuration.
|
||||
mailer:
|
||||
# SMTP mailer configuration.
|
||||
smtp:
|
||||
# Enable/Disable smtp mailer.
|
||||
enable: true
|
||||
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||
host: {{ get_env(name="MAILER_HOST", default="localhost") }}
|
||||
# SMTP server port
|
||||
port: 1025
|
||||
# Use secure connection (SSL/TLS).
|
||||
secure: false
|
||||
# auth:
|
||||
# user:
|
||||
# password:
|
||||
|
||||
# Initializers Configuration
|
||||
# initializers:
|
||||
# oauth2:
|
||||
# authorization_code: # Authorization code grant type
|
||||
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
||||
# ... other fields
|
||||
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{ get_env(name="DATABASE_URL", default="sqlite://daily_aujourdhui_development.sqlite?mode=rwc") }}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
|
||||
# Set the idle duration before closing a connection.
|
||||
idle_timeout: 500
|
||||
# Minimum number of connections for a pool.
|
||||
min_connections: 1
|
||||
# Maximum number of connections for a pool.
|
||||
max_connections: 1
|
||||
# Run migration up when application loaded
|
||||
auto_migrate: true
|
||||
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_truncate: false
|
||||
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_recreate: false
|
||||
|
||||
# Queue Configuration
|
||||
queue:
|
||||
kind: Redis
|
||||
# Redis connection URI
|
||||
uri: {{ get_env(name="REDIS_URL", default="redis://127.0.0.1") }}
|
||||
# Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_flush: false
|
||||
|
||||
# Authentication Configuration
|
||||
auth:
|
||||
# JWT authentication
|
||||
jwt:
|
||||
# Secret key for token generation and verification
|
||||
secret: fwSH8LiNizFRFZR2zZTO
|
||||
# Token expiration time in seconds
|
||||
expiration: 604800 # 7 days
|
101
config/test.yaml
Normal file
101
config/test.yaml
Normal file
@ -0,0 +1,101 @@
|
||||
# Loco configuration file documentation
|
||||
|
||||
# Application logging configuration
|
||||
logger:
|
||||
# Enable or disable logging.
|
||||
enable: false
|
||||
# Log level, options: trace, debug, info, warn or error.
|
||||
level: debug
|
||||
# Define the logging format. options: compact, pretty or json
|
||||
format: compact
|
||||
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
|
||||
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
|
||||
# override_filter: trace
|
||||
|
||||
# Web server configuration
|
||||
server:
|
||||
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
|
||||
port: 5150
|
||||
# The UI hostname or IP address that mailers will point to.
|
||||
host: http://localhost
|
||||
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
|
||||
middlewares:
|
||||
static:
|
||||
enable: true
|
||||
precompressed: false
|
||||
must_exist: false
|
||||
folder:
|
||||
uri: "/"
|
||||
path: "frontend/dist"
|
||||
fallback: "frontend/dist/index.html"
|
||||
|
||||
# Worker Configuration
|
||||
workers:
|
||||
# specifies the worker mode. Options:
|
||||
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
|
||||
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
|
||||
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
|
||||
mode: ForegroundBlocking
|
||||
|
||||
# Mailer Configuration.
|
||||
mailer:
|
||||
# SMTP mailer configuration.
|
||||
smtp:
|
||||
# Enable/Disable smtp mailer.
|
||||
enable: true
|
||||
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||
host: localhost
|
||||
# SMTP server port
|
||||
port: 1025
|
||||
# Use secure connection (SSL/TLS).
|
||||
secure: false
|
||||
# auth:
|
||||
# user:
|
||||
# password:
|
||||
stub: true
|
||||
|
||||
# Initializers Configuration
|
||||
# initializers:
|
||||
# oauth2:
|
||||
# authorization_code: # Authorization code grant type
|
||||
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
||||
# ... other fields
|
||||
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{get_env(name="DATABASE_URL", default="sqlite://daily_aujourdhui_test.sqlite?mode=rwc")}}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
|
||||
# Set the idle duration before closing a connection.
|
||||
idle_timeout: 500
|
||||
# Minimum number of connections for a pool.
|
||||
min_connections: 1
|
||||
# Maximum number of connections for a pool.
|
||||
max_connections: 1
|
||||
# Run migration up when application loaded
|
||||
auto_migrate: true
|
||||
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_truncate: true
|
||||
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_recreate: false
|
||||
|
||||
# Queue Configuration
|
||||
queue:
|
||||
kind: Redis
|
||||
# Redis connection URI
|
||||
uri: {{get_env(name="REDIS_URL", default="redis://127.0.0.1")}}
|
||||
# Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||
dangerously_flush: false
|
||||
|
||||
# Authentication Configuration
|
||||
auth:
|
||||
# JWT authentication
|
||||
jwt:
|
||||
# Secret key for token generation and verification
|
||||
secret: YOfElH8VE2SHOVCo65Xu
|
||||
# Token expiration time in seconds
|
||||
expiration: 604800 # 7 days
|
||||
|
21
examples/playground.rs
Normal file
21
examples/playground.rs
Normal file
@ -0,0 +1,21 @@
|
||||
#[allow(unused_imports)]
|
||||
use loco_rs::{cli::playground, prelude::*};
|
||||
use daily_aujourdhui::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
let _ctx = playground::<App>().await?;
|
||||
|
||||
// let active_model: articles::ActiveModel = ActiveModel {
|
||||
// title: Set(Some("how to build apps in 3 steps".to_string())),
|
||||
// content: Set(Some("use Loco: https://loco.rs".to_string())),
|
||||
// ..Default::default()
|
||||
// };
|
||||
// active_model.insert(&ctx.db).await.unwrap();
|
||||
|
||||
// let res = articles::Entity::find().all(&ctx.db).await.unwrap();
|
||||
// println!("{:?}", res);
|
||||
println!("welcome to playground. edit me at `examples/playground.rs`");
|
||||
|
||||
Ok(())
|
||||
}
|
96
flake.lock
generated
Normal file
96
flake.lock
generated
Normal file
@ -0,0 +1,96 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1733212471,
|
||||
"narHash": "sha256-M1+uCoV5igihRfcUKrr1riygbe73/dzNnzPsmaLCmpo=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "55d15ad12a74eb7d4646254e13638ad0c4128776",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1728538411,
|
||||
"narHash": "sha256-f0SBJz1eZ2yOuKUr5CA9BHULGXVSn6miBuUWdTyhUhU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b69de56fac8c2b6f8fd27f2eca01dcda8e0a4221",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1733279627,
|
||||
"narHash": "sha256-NCNDAGPkdFdu+DLErbmNbavmVW9AwkgP7azROFFSB0U=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "4da5a80ef76039e80468c902f1e9f5c0eab87d96",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
66
flake.nix
Normal file
66
flake.nix
Normal file
@ -0,0 +1,66 @@
|
||||
{
|
||||
description = "";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
||||
flake-utils.lib.eachSystem ["x86_64-linux"] (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
rustVersion = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml);
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustVersion;
|
||||
rustc = rustVersion;
|
||||
};
|
||||
|
||||
appName = "rse-backend";
|
||||
|
||||
appRustBuild = rustPlatform.buildRustPackage {
|
||||
pname = appName;
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
doCheck = false;
|
||||
};
|
||||
|
||||
dockerImage = pkgs.dockerTools.buildLayeredImage {
|
||||
name = appName;
|
||||
config = {
|
||||
Entrypoint = [ "${appRustBuild}/bin/${appName}" ];
|
||||
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
|
||||
Tag = "latest";
|
||||
};
|
||||
contents = [ appRustBuild pkgs.cacert ];
|
||||
};
|
||||
in {
|
||||
packages = {
|
||||
rustPackage = appRustBuild;
|
||||
docker = dockerImage;
|
||||
};
|
||||
defaultPackage = dockerImage;
|
||||
devShell = with pkgs; mkShell {
|
||||
buildInputs = [
|
||||
bacon
|
||||
cargo
|
||||
cargo-audit
|
||||
cargo-auditable
|
||||
cargo-msrv
|
||||
cargo-tarpaulin
|
||||
dockerfile-language-server-nodejs
|
||||
just
|
||||
loco-cli
|
||||
nodePackages."@volar/vue-language-server"
|
||||
nodejs_20
|
||||
rust-analyzer
|
||||
rustVersion
|
||||
sea-orm-cli
|
||||
vscode-langservers-extracted
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
11
frontend/.dir-locals.el
Normal file
11
frontend/.dir-locals.el
Normal file
@ -0,0 +1,11 @@
|
||||
;;; Directory Local Variables -*- no-byte-compile: t -*-
|
||||
;;; For more information see (info "(emacs) Directory Variables")
|
||||
|
||||
((js-json-mode . ((js-indent-level . 2)))
|
||||
(web-mode . ((web-mode-style-padding . 0)
|
||||
(web-mode-script-padding . 0)
|
||||
(emmet-indentation . 2)
|
||||
(web-mode-code-indent-offset . 2)
|
||||
(web-mode-markup-comment-indent-offset . 2)
|
||||
(web-mode-markup-indent-offset . 2)
|
||||
(web-mode-css-indent-offset . 2))))
|
27
frontend/.gitignore
vendored
Normal file
27
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
6
frontend/.vscode/extensions.json
vendored
Normal file
6
frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer"
|
||||
]
|
||||
}
|
39
frontend/README.md
Normal file
39
frontend/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# daily-aujourdhui
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
npm run test:unit
|
||||
```
|
1
frontend/env.d.ts
vendored
Normal file
1
frontend/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
5728
frontend/package-lock.json
generated
Normal file
5728
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/package.json
Normal file
39
frontend/package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "daily-aujourdhui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "biome check src/",
|
||||
"format": "biome format --write src/",
|
||||
"check": "biome check --write src/",
|
||||
"ci": "biome ci src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@tsconfig/node22": "^22.0.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^22.9.3",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"npm-run-all2": "^7.0.1",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.1",
|
||||
"vite-plugin-vue-devtools": "^7.6.5",
|
||||
"vitest": "^2.1.5",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
85
frontend/src/App.vue
Normal file
85
frontend/src/App.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from "vue-router";
|
||||
import HelloWorld from "./components/HelloWorld.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
74
frontend/src/assets/base.css
Normal file
74
frontend/src/assets/base.css
Normal file
@ -0,0 +1,74 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition: color 0.5s, background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
1
frontend/src/assets/logo.svg
Normal file
1
frontend/src/assets/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
After Width: | Height: | Size: 276 B |
35
frontend/src/assets/main.css
Normal file
35
frontend/src/assets/main.css
Normal file
@ -0,0 +1,35 @@
|
||||
@import "./base.css";
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
90
frontend/src/components/TheWelcome.vue
Normal file
90
frontend/src/components/TheWelcome.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from "./WelcomeItem.vue";
|
||||
import CommunityIcon from "./icons/IconCommunity.vue";
|
||||
import DocumentationIcon from "./icons/IconDocumentation.vue";
|
||||
import EcosystemIcon from "./icons/IconEcosystem.vue";
|
||||
import SupportIcon from "./icons/IconSupport.vue";
|
||||
import ToolingIcon from "./icons/IconTooling.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
and
|
||||
<a href="https://on.cypress.io/component" target="_blank" rel="noopener"
|
||||
>Cypress Component Testing</a
|
||||
>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in <code>README.md</code>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
|
||||
Discord server, or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also subscribe to
|
||||
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a>
|
||||
and follow the official
|
||||
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
twitter account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
87
frontend/src/components/WelcomeItem.vue
Normal file
87
frontend/src/components/WelcomeItem.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
11
frontend/src/components/__tests__/HelloWorld.spec.ts
Normal file
11
frontend/src/components/__tests__/HelloWorld.spec.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import HelloWorld from "../HelloWorld.vue";
|
||||
|
||||
describe("HelloWorld", () => {
|
||||
it("renders properly", () => {
|
||||
const wrapper = mount(HelloWorld, { props: { msg: "Hello Vitest" } });
|
||||
expect(wrapper.text()).toContain("Hello Vitest");
|
||||
});
|
||||
});
|
7
frontend/src/components/icons/IconCommunity.vue
Normal file
7
frontend/src/components/icons/IconCommunity.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
7
frontend/src/components/icons/IconDocumentation.vue
Normal file
7
frontend/src/components/icons/IconDocumentation.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
7
frontend/src/components/icons/IconEcosystem.vue
Normal file
7
frontend/src/components/icons/IconEcosystem.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
7
frontend/src/components/icons/IconSupport.vue
Normal file
7
frontend/src/components/icons/IconSupport.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
19
frontend/src/components/icons/IconTooling.vue
Normal file
19
frontend/src/components/icons/IconTooling.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
14
frontend/src/main.ts
Normal file
14
frontend/src/main.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import "./assets/main.css";
|
||||
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
app.mount("#app");
|
23
frontend/src/router/index.ts
Normal file
23
frontend/src/router/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
name: "about",
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import("../views/AboutView.vue"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
12
frontend/src/stores/counter.ts
Normal file
12
frontend/src/stores/counter.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
export const useCounterStore = defineStore("counter", () => {
|
||||
const count = ref(0);
|
||||
const doubleCount = computed(() => count.value * 2);
|
||||
function increment() {
|
||||
count.value++;
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment };
|
||||
});
|
15
frontend/src/views/AboutView.vue
Normal file
15
frontend/src/views/AboutView.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
9
frontend/src/views/HomeView.vue
Normal file
9
frontend/src/views/HomeView.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from "../components/TheWelcome.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
</template>
|
13
frontend/tsconfig.app.json
Normal file
13
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
19
frontend/tsconfig.node.json
Normal file
19
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
11
frontend/tsconfig.vitest.json
Normal file
11
frontend/tsconfig.vitest.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
14
frontend/vitest.config.ts
Normal file
14
frontend/vitest.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
22
migration/Cargo.toml
Normal file
22
migration/Cargo.toml
Normal file
@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "migration"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||
loco-rs = { workspace = true }
|
||||
|
||||
[dependencies.sea-orm-migration]
|
||||
version = "1.1.0"
|
||||
features = [
|
||||
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
|
||||
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
||||
# e.g.
|
||||
"runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
|
||||
]
|
18
migration/src/lib.rs
Normal file
18
migration/src/lib.rs
Normal file
@ -0,0 +1,18 @@
|
||||
#![allow(elided_lifetimes_in_paths)]
|
||||
#![allow(clippy::wildcard_imports)]
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20220101_000001_users;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
// inject-below (do not remove this comment)
|
||||
Box::new(m20220101_000001_users::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
}
|
50
migration/src/m20220101_000001_users.rs
Normal file
50
migration/src/m20220101_000001_users.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use loco_rs::schema::table_auto_tz;
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
let table = table_auto_tz(Users::Table)
|
||||
.col(pk_auto(Users::Id))
|
||||
.col(uuid(Users::Pid))
|
||||
.col(string_uniq(Users::Email))
|
||||
.col(string(Users::Password))
|
||||
.col(string(Users::ApiKey).unique_key())
|
||||
.col(string(Users::Name))
|
||||
.col(string_null(Users::ResetToken))
|
||||
.col(timestamp_with_time_zone_null(Users::ResetSentAt))
|
||||
.col(string_null(Users::EmailVerificationToken))
|
||||
.col(timestamp_with_time_zone_null(
|
||||
Users::EmailVerificationSentAt,
|
||||
))
|
||||
.col(timestamp_with_time_zone_null(Users::EmailVerifiedAt))
|
||||
.to_owned();
|
||||
manager.create_table(table).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Users::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub enum Users {
|
||||
Table,
|
||||
Id,
|
||||
Pid,
|
||||
Email,
|
||||
Name,
|
||||
Password,
|
||||
ApiKey,
|
||||
ResetToken,
|
||||
ResetSentAt,
|
||||
EmailVerificationToken,
|
||||
EmailVerificationSentAt,
|
||||
EmailVerifiedAt,
|
||||
}
|
4
rust-toolchain.toml
Normal file
4
rust-toolchain.toml
Normal file
@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.81.0"
|
||||
components = [ "rustfmt", "rust-src", "clippy", "rust-analyzer" ]
|
||||
profile = "default"
|
71
src/app.rs
Normal file
71
src/app.rs
Normal file
@ -0,0 +1,71 @@
|
||||
use std::path::Path;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use loco_rs::{
|
||||
app::{AppContext, Hooks, Initializer},
|
||||
bgworker::{BackgroundWorker, Queue},
|
||||
boot::{create_app, BootResult, StartMode},
|
||||
controller::AppRoutes,
|
||||
db::{self, truncate_table},
|
||||
environment::Environment,
|
||||
task::Tasks,
|
||||
Result,
|
||||
};
|
||||
use migration::Migrator;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::{
|
||||
controllers, initializers, models::_entities::users, tasks, workers::downloader::DownloadWorker,
|
||||
};
|
||||
|
||||
pub struct App;
|
||||
#[async_trait]
|
||||
impl Hooks for App {
|
||||
fn app_name() -> &'static str {
|
||||
env!("CARGO_CRATE_NAME")
|
||||
}
|
||||
|
||||
fn app_version() -> String {
|
||||
format!(
|
||||
"{} ({})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
option_env!("BUILD_SHA")
|
||||
.or(option_env!("GITHUB_SHA"))
|
||||
.unwrap_or("dev")
|
||||
)
|
||||
}
|
||||
|
||||
async fn boot(mode: StartMode, environment: &Environment) -> Result<BootResult> {
|
||||
create_app::<Self, Migrator>(mode, environment).await
|
||||
}
|
||||
|
||||
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
|
||||
Ok(vec![Box::new(
|
||||
initializers::view_engine::ViewEngineInitializer,
|
||||
)])
|
||||
}
|
||||
|
||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||
AppRoutes::with_default_routes() // controller routes below
|
||||
.add_route(controllers::auth::routes())
|
||||
}
|
||||
|
||||
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||
queue.register(DownloadWorker::build(ctx)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_tasks(tasks: &mut Tasks) {
|
||||
tasks.register(tasks::seed::SeedData);
|
||||
}
|
||||
|
||||
async fn truncate(db: &DatabaseConnection) -> Result<()> {
|
||||
truncate_table(db, users::Entity).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> {
|
||||
db::seed::<users::ActiveModel>(db, &base.join("users.yaml").display().to_string()).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
8
src/bin/main.rs
Normal file
8
src/bin/main.rs
Normal file
@ -0,0 +1,8 @@
|
||||
use loco_rs::cli;
|
||||
use daily_aujourdhui::app::App;
|
||||
use migration::Migrator;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
cli::main::<App, Migrator>().await
|
||||
}
|
8
src/bin/tool.rs
Normal file
8
src/bin/tool.rs
Normal file
@ -0,0 +1,8 @@
|
||||
use loco_rs::cli;
|
||||
use daily_aujourdhui::app::App;
|
||||
use migration::Migrator;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> loco_rs::Result<()> {
|
||||
cli::main::<App, Migrator>().await
|
||||
}
|
157
src/controllers/auth.rs
Normal file
157
src/controllers/auth.rs
Normal file
@ -0,0 +1,157 @@
|
||||
use axum::debug_handler;
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
mailers::auth::AuthMailer,
|
||||
models::{
|
||||
_entities::users,
|
||||
users::{LoginParams, RegisterParams},
|
||||
},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
};
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct VerifyParams {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ForgotParams {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ResetParams {
|
||||
pub token: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Register function creates a new user with the given parameters and sends a
|
||||
/// welcome email to the user
|
||||
#[debug_handler]
|
||||
async fn register(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<RegisterParams>,
|
||||
) -> Result<Response> {
|
||||
let res = users::Model::create_with_password(&ctx.db, ¶ms).await;
|
||||
|
||||
let user = match res {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
tracing::info!(
|
||||
message = err.to_string(),
|
||||
user_email = ¶ms.email,
|
||||
"could not register user",
|
||||
);
|
||||
return format::json(());
|
||||
}
|
||||
};
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// Verify register user. if the user not verified his email, he can't login to
|
||||
/// the system.
|
||||
#[debug_handler]
|
||||
async fn verify(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<VerifyParams>,
|
||||
) -> Result<Response> {
|
||||
let user = users::Model::find_by_verification_token(&ctx.db, ¶ms.token).await?;
|
||||
|
||||
if user.email_verified_at.is_some() {
|
||||
tracing::info!(pid = user.pid.to_string(), "user already verified");
|
||||
} else {
|
||||
let active_model = user.into_active_model();
|
||||
let user = active_model.verified(&ctx.db).await?;
|
||||
tracing::info!(pid = user.pid.to_string(), "user verified");
|
||||
}
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// In case the user forgot his password this endpoints generate a forgot token
|
||||
/// and send email to the user. In case the email not found in our DB, we are
|
||||
/// returning a valid request for for security reasons (not exposing users DB
|
||||
/// list).
|
||||
#[debug_handler]
|
||||
async fn forgot(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<ForgotParams>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
return format::json(());
|
||||
};
|
||||
|
||||
let user = user
|
||||
.into_active_model()
|
||||
.set_forgot_password_sent(&ctx.db)
|
||||
.await?;
|
||||
|
||||
AuthMailer::forgot_password(&ctx, &user).await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// reset user password by the given parameters
|
||||
#[debug_handler]
|
||||
async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else {
|
||||
// we don't want to expose our users email. if the email is invalid we still
|
||||
// returning success to the caller
|
||||
tracing::info!("reset token not found");
|
||||
|
||||
return format::json(());
|
||||
};
|
||||
user.into_active_model()
|
||||
.reset_password(&ctx.db, ¶ms.password)
|
||||
.await?;
|
||||
|
||||
format::json(())
|
||||
}
|
||||
|
||||
/// Creates a user login and returns a token
|
||||
#[debug_handler]
|
||||
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
|
||||
let user = users::Model::find_by_email(&ctx.db, ¶ms.email).await?;
|
||||
|
||||
let valid = user.verify_password(¶ms.password);
|
||||
|
||||
if !valid {
|
||||
return unauthorized("unauthorized!");
|
||||
}
|
||||
|
||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||
|
||||
let token = user
|
||||
.generate_jwt(&jwt_secret.secret, &jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::json(LoginResponse::new(&user, &token))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
format::json(CurrentResponse::new(&user))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/auth")
|
||||
.add("/register", post(register))
|
||||
.add("/verify", post(verify))
|
||||
.add("/login", post(login))
|
||||
.add("/forgot", post(forgot))
|
||||
.add("/reset", post(reset))
|
||||
.add("/current", get(current))
|
||||
}
|
1
src/controllers/mod.rs
Normal file
1
src/controllers/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod auth;
|
17
src/fixtures/users.yaml
Normal file
17
src/fixtures/users.yaml
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
- id: 1
|
||||
pid: 11111111-1111-1111-1111-111111111111
|
||||
email: user1@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||
name: user1
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
- id: 2
|
||||
pid: 22222222-2222-2222-2222-222222222222
|
||||
email: user2@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||
name: user2
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
2
src/initializers/mod.rs
Normal file
2
src/initializers/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
#![allow(clippy::module_name_repetitions)]
|
||||
pub mod view_engine;
|
36
src/initializers/view_engine.rs
Normal file
36
src/initializers/view_engine.rs
Normal file
@ -0,0 +1,36 @@
|
||||
use axum::{async_trait, Extension, Router as AxumRouter};
|
||||
use fluent_templates::{ArcLoader, FluentLoader};
|
||||
use loco_rs::{
|
||||
app::{AppContext, Initializer},
|
||||
controller::views::{engines, ViewEngine},
|
||||
Error, Result,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
const I18N_DIR: &str = "assets/i18n";
|
||||
const I18N_SHARED: &str = "assets/i18n/shared.ftl";
|
||||
|
||||
pub struct ViewEngineInitializer;
|
||||
#[async_trait]
|
||||
impl Initializer for ViewEngineInitializer {
|
||||
fn name(&self) -> String {
|
||||
"view-engine".to_string()
|
||||
}
|
||||
|
||||
async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||
let mut tera_engine = engines::TeraView::build()?;
|
||||
if std::path::Path::new(I18N_DIR).exists() {
|
||||
let arc = ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US"))
|
||||
.shared_resources(Some(&[I18N_SHARED.into()]))
|
||||
.customize(|bundle| bundle.set_use_isolating(false))
|
||||
.build()
|
||||
.map_err(|e| Error::string(&e.to_string()))?;
|
||||
tera_engine
|
||||
.tera
|
||||
.register_function("t", FluentLoader::new(arc));
|
||||
info!("locales loaded");
|
||||
}
|
||||
|
||||
Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
|
||||
}
|
||||
}
|
8
src/lib.rs
Normal file
8
src/lib.rs
Normal file
@ -0,0 +1,8 @@
|
||||
pub mod app;
|
||||
pub mod controllers;
|
||||
pub mod initializers;
|
||||
pub mod mailers;
|
||||
pub mod models;
|
||||
pub mod tasks;
|
||||
pub mod views;
|
||||
pub mod workers;
|
65
src/mailers/auth.rs
Normal file
65
src/mailers/auth.rs
Normal file
@ -0,0 +1,65 @@
|
||||
// auth mailer
|
||||
#![allow(non_upper_case_globals)]
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::models::users;
|
||||
|
||||
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||
// #[derive(Mailer)] // -- disabled for faster build speed. it works. but lets
|
||||
// move on for now.
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct AuthMailer {}
|
||||
impl Mailer for AuthMailer {}
|
||||
impl AuthMailer {
|
||||
/// Sending welcome email the the given user
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When email sending is failed
|
||||
pub async fn send_welcome(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
&welcome,
|
||||
mailer::Args {
|
||||
to: user.email.to_string(),
|
||||
locals: json!({
|
||||
"name": user.name,
|
||||
"verifyToken": user.email_verification_token,
|
||||
"domain": ctx.config.server.full_url()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sending forgot password email
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When email sending is failed
|
||||
pub async fn forgot_password(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||
Self::mail_template(
|
||||
ctx,
|
||||
&forgot,
|
||||
mailer::Args {
|
||||
to: user.email.to_string(),
|
||||
locals: json!({
|
||||
"name": user.name,
|
||||
"resetToken": user.reset_token,
|
||||
"domain": ctx.config.server.full_url()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
11
src/mailers/auth/forgot/html.t
Normal file
11
src/mailers/auth/forgot/html.t
Normal file
@ -0,0 +1,11 @@
|
||||
;<html>
|
||||
|
||||
<body>
|
||||
Hey {{name}},
|
||||
Forgot your password? No worries! You can reset it by clicking the link below:
|
||||
<a href="http://{{domain}}/reset#{{resetToken}}">Reset Your Password</a>
|
||||
If you didn't request a password reset, please ignore this email.
|
||||
Best regards,<br>The Loco Team</br>
|
||||
</body>
|
||||
|
||||
</html>
|
1
src/mailers/auth/forgot/subject.t
Normal file
1
src/mailers/auth/forgot/subject.t
Normal file
@ -0,0 +1 @@
|
||||
Your reset password link
|
3
src/mailers/auth/forgot/text.t
Normal file
3
src/mailers/auth/forgot/text.t
Normal file
@ -0,0 +1,3 @@
|
||||
Reset your password with this link:
|
||||
|
||||
http://localhost/reset#{{resetToken}}
|
13
src/mailers/auth/welcome/html.t
Normal file
13
src/mailers/auth/welcome/html.t
Normal file
@ -0,0 +1,13 @@
|
||||
;<html>
|
||||
|
||||
<body>
|
||||
Dear {{name}},
|
||||
Welcome to Loco! You can now log in to your account.
|
||||
Before you get started, please verify your account by clicking the link below:
|
||||
<a href="http://{{domain}}/verify#{{verifyToken}}">
|
||||
Verify Your Account
|
||||
</a>
|
||||
<p>Best regards,<br>The Loco Team</p>
|
||||
</body>
|
||||
|
||||
</html>
|
1
src/mailers/auth/welcome/subject.t
Normal file
1
src/mailers/auth/welcome/subject.t
Normal file
@ -0,0 +1 @@
|
||||
Welcome {{name}}
|
4
src/mailers/auth/welcome/text.t
Normal file
4
src/mailers/auth/welcome/text.t
Normal file
@ -0,0 +1,4 @@
|
||||
Welcome {{name}}, you can now log in.
|
||||
Verify your account with the link below:
|
||||
|
||||
http://localhost/verify#{{verifyToken}}
|
1
src/mailers/mod.rs
Normal file
1
src/mailers/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod auth;
|
5
src/models/_entities/mod.rs
Normal file
5
src/models/_entities/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod users;
|
3
src/models/_entities/prelude.rs
Normal file
3
src/models/_entities/prelude.rs
Normal file
@ -0,0 +1,3 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
pub use super::users::Entity as Users;
|
28
src/models/_entities/users.rs
Normal file
28
src/models/_entities/users.rs
Normal file
@ -0,0 +1,28 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub pid: Uuid,
|
||||
#[sea_orm(unique)]
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
#[sea_orm(unique)]
|
||||
pub api_key: String,
|
||||
pub name: String,
|
||||
pub reset_token: Option<String>,
|
||||
pub reset_sent_at: Option<DateTimeWithTimeZone>,
|
||||
pub email_verification_token: Option<String>,
|
||||
pub email_verification_sent_at: Option<DateTimeWithTimeZone>,
|
||||
pub email_verified_at: Option<DateTimeWithTimeZone>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
2
src/models/mod.rs
Normal file
2
src/models/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod _entities;
|
||||
pub mod users;
|
298
src/models/users.rs
Normal file
298
src/models/users.rs
Normal file
@ -0,0 +1,298 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::offset::Local;
|
||||
use loco_rs::{auth::jwt, hash, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use super::_entities::users::{self, ActiveModel, Entity, Model};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct LoginParams {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RegisterParams {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Validate, Deserialize)]
|
||||
pub struct Validator {
|
||||
#[validate(length(min = 2, message = "Name must be at least 2 characters long."))]
|
||||
pub name: String,
|
||||
#[validate(custom(function = "validation::is_valid_email"))]
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl Validatable for super::_entities::users::ActiveModel {
|
||||
fn validator(&self) -> Box<dyn Validate> {
|
||||
Box::new(Validator {
|
||||
name: self.name.as_ref().to_owned(),
|
||||
email: self.email.as_ref().to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for super::_entities::users::ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
self.validate()?;
|
||||
if insert {
|
||||
let mut this = self;
|
||||
this.pid = ActiveValue::Set(Uuid::new_v4());
|
||||
this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4()));
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Authenticable for super::_entities::users::Model {
|
||||
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::ApiKey, api_key)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self> {
|
||||
Self::find_by_pid(db, claims_key).await
|
||||
}
|
||||
}
|
||||
|
||||
impl super::_entities::users::Model {
|
||||
/// finds a user by the provided email
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::Email, email)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided verification token
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_verification_token(
|
||||
db: &DatabaseConnection,
|
||||
token: &str,
|
||||
) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::EmailVerificationToken, token)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided reset token
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::ResetToken, token)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided pid
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user or DB query error
|
||||
pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult<Self> {
|
||||
let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?;
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::Pid, parse_uuid)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// finds a user by the provided api key
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not find user by the given token or DB query error
|
||||
pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
|
||||
let user = users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::ApiKey, api_key)
|
||||
.build(),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
user.ok_or_else(|| ModelError::EntityNotFound)
|
||||
}
|
||||
|
||||
/// Verifies whether the provided plain password matches the hashed password
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when could not verify password
|
||||
#[must_use]
|
||||
pub fn verify_password(&self, password: &str) -> bool {
|
||||
hash::verify_password(password, &self.password)
|
||||
}
|
||||
|
||||
/// Asynchronously creates a user with a password and saves it to the
|
||||
/// database.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// When could not save the user into the DB
|
||||
pub async fn create_with_password(
|
||||
db: &DatabaseConnection,
|
||||
params: &RegisterParams,
|
||||
) -> ModelResult<Self> {
|
||||
let txn = db.begin().await?;
|
||||
|
||||
if users::Entity::find()
|
||||
.filter(
|
||||
model::query::condition()
|
||||
.eq(users::Column::Email, ¶ms.email)
|
||||
.build(),
|
||||
)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ModelError::EntityAlreadyExists {});
|
||||
}
|
||||
|
||||
let password_hash =
|
||||
hash::hash_password(¶ms.password).map_err(|e| ModelError::Any(e.into()))?;
|
||||
let user = users::ActiveModel {
|
||||
email: ActiveValue::set(params.email.to_string()),
|
||||
password: ActiveValue::set(password_hash),
|
||||
name: ActiveValue::set(params.name.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Creates a JWT
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when could not convert user claims to jwt token
|
||||
pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult<String> {
|
||||
Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), None)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl super::_entities::users::ActiveModel {
|
||||
/// Sets the email verification information for the user and
|
||||
/// updates it in the database.
|
||||
///
|
||||
/// This method is used to record the timestamp when the email verification
|
||||
/// was sent and generate a unique verification token for the user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error
|
||||
pub async fn set_email_verification_sent(
|
||||
mut self,
|
||||
db: &DatabaseConnection,
|
||||
) -> ModelResult<Model> {
|
||||
self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into()));
|
||||
self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
|
||||
Ok(self.update(db).await?)
|
||||
}
|
||||
|
||||
/// Sets the information for a reset password request,
|
||||
/// generates a unique reset password token, and updates it in the
|
||||
/// database.
|
||||
///
|
||||
/// This method records the timestamp when the reset password token is sent
|
||||
/// and generates a unique token for the user.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error
|
||||
pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
self.reset_sent_at = ActiveValue::set(Some(Local::now().into()));
|
||||
self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
|
||||
Ok(self.update(db).await?)
|
||||
}
|
||||
|
||||
/// Records the verification time when a user verifies their
|
||||
/// email and updates it in the database.
|
||||
///
|
||||
/// This method sets the timestamp when the user successfully verifies their
|
||||
/// email.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error
|
||||
pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
|
||||
self.email_verified_at = ActiveValue::set(Some(Local::now().into()));
|
||||
Ok(self.update(db).await?)
|
||||
}
|
||||
|
||||
/// Resets the current user password with a new password and
|
||||
/// updates it in the database.
|
||||
///
|
||||
/// This method hashes the provided password and sets it as the new password
|
||||
/// for the user.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// when has DB query error or could not hashed the given password
|
||||
pub async fn reset_password(
|
||||
mut self,
|
||||
db: &DatabaseConnection,
|
||||
password: &str,
|
||||
) -> ModelResult<Model> {
|
||||
self.password =
|
||||
ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?);
|
||||
self.reset_token = ActiveValue::Set(None);
|
||||
self.reset_sent_at = ActiveValue::Set(None);
|
||||
Ok(self.update(db).await?)
|
||||
}
|
||||
}
|
1
src/tasks/mod.rs
Normal file
1
src/tasks/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod seed;
|
45
src/tasks/seed.rs
Normal file
45
src/tasks/seed.rs
Normal file
@ -0,0 +1,45 @@
|
||||
//! This task implements data seeding functionality for initializing new
|
||||
//! development/demo environments.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! Run the task with the following command:
|
||||
//! ```sh
|
||||
//! cargo run task
|
||||
//! ```
|
||||
//!
|
||||
//! To override existing data and reset the data structure, use the following
|
||||
//! command with the `refresh:true` argument:
|
||||
//! ```sh
|
||||
//! cargo run task seed_data refresh:true
|
||||
//! ```
|
||||
|
||||
use loco_rs::{db, prelude::*};
|
||||
use migration::Migrator;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct SeedData;
|
||||
#[async_trait]
|
||||
impl Task for SeedData {
|
||||
fn task(&self) -> TaskInfo {
|
||||
TaskInfo {
|
||||
name: "seed_data".to_string(),
|
||||
detail: "Task for seeding data".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, app_context: &AppContext, vars: &task::Vars) -> Result<()> {
|
||||
let refresh = vars
|
||||
.cli_arg("refresh")
|
||||
.is_ok_and(|refresh| refresh == "true");
|
||||
|
||||
if refresh {
|
||||
db::reset::<Migrator>(&app_context.db).await?;
|
||||
}
|
||||
let path = std::path::Path::new("src/fixtures");
|
||||
db::run_app_seed::<App>(&app_context.db, path).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
41
src/views/auth.rs
Normal file
41
src/views/auth.rs
Normal file
@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::users;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub is_verified: bool,
|
||||
}
|
||||
|
||||
impl LoginResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model, token: &String) -> Self {
|
||||
Self {
|
||||
token: token.to_string(),
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
is_verified: user.email_verified_at.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CurrentResponse {
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl CurrentResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model) -> Self {
|
||||
Self {
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
email: user.email.clone(),
|
||||
}
|
||||
}
|
||||
}
|
1
src/views/mod.rs
Normal file
1
src/views/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod auth;
|
40
src/workers/downloader.rs
Normal file
40
src/workers/downloader.rs
Normal file
@ -0,0 +1,40 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::models::users;
|
||||
|
||||
pub struct DownloadWorker {
|
||||
pub ctx: AppContext,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Serialize)]
|
||||
pub struct DownloadWorkerArgs {
|
||||
pub user_guid: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackgroundWorker<DownloadWorkerArgs> for DownloadWorker {
|
||||
fn build(ctx: &AppContext) -> Self {
|
||||
Self { ctx: ctx.clone() }
|
||||
}
|
||||
async fn perform(&self, args: DownloadWorkerArgs) -> Result<()> {
|
||||
// TODO: Some actual work goes here...
|
||||
println!("================================================");
|
||||
println!("Sending payment report to user {}", args.user_guid);
|
||||
|
||||
sleep(Duration::from_millis(2000)).await;
|
||||
|
||||
let all = users::Entity::find()
|
||||
.all(&self.ctx.db)
|
||||
.await
|
||||
.map_err(Box::from)?;
|
||||
for user in &all {
|
||||
println!("user: {}", user.id);
|
||||
}
|
||||
println!("================================================");
|
||||
Ok(())
|
||||
}
|
||||
}
|
1
src/workers/mod.rs
Normal file
1
src/workers/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod downloader;
|
4
tests/mod.rs
Normal file
4
tests/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
mod models;
|
||||
mod requests;
|
||||
mod tasks;
|
||||
mod workers;
|
1
tests/models/mod.rs
Normal file
1
tests/models/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
mod users;
|
21
tests/models/snapshots/can_create_with_password@users.snap
Normal file
21
tests/models/snapshots/can_create_with_password@users.snap
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: res
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: DATE,
|
||||
updated_at: DATE,
|
||||
id: ID
|
||||
pid: PID,
|
||||
email: "test@framework.com",
|
||||
password: "PASSWORD",
|
||||
api_key: "lo-PID",
|
||||
name: "framework",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: None,
|
||||
email_verification_sent_at: None,
|
||||
email_verified_at: None,
|
||||
},
|
||||
)
|
7
tests/models/snapshots/can_find_by_email-2@users.snap
Normal file
7
tests/models/snapshots/can_find_by_email-2@users.snap
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/main.rs
|
||||
expression: non_existing_user_results
|
||||
---
|
||||
Err(
|
||||
EntityNotFound,
|
||||
)
|
7
tests/models/snapshots/can_find_by_email@users-2.snap
Normal file
7
tests/models/snapshots/can_find_by_email@users-2.snap
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: non_existing_user_results
|
||||
---
|
||||
Err(
|
||||
EntityNotFound,
|
||||
)
|
21
tests/models/snapshots/can_find_by_email@users.snap
Normal file
21
tests/models/snapshots/can_find_by_email@users.snap
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: existing_user
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: 2023-11-12T12:34:56.789+00:00,
|
||||
updated_at: 2023-11-12T12:34:56.789+00:00,
|
||||
id: 1,
|
||||
pid: 11111111-1111-1111-1111-111111111111,
|
||||
email: "user1@example.com",
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
|
||||
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
|
||||
name: "user1",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: None,
|
||||
email_verification_sent_at: None,
|
||||
email_verified_at: None,
|
||||
},
|
||||
)
|
7
tests/models/snapshots/can_find_by_pid-2@users.snap
Normal file
7
tests/models/snapshots/can_find_by_pid-2@users.snap
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/main.rs
|
||||
expression: non_existing_user_results
|
||||
---
|
||||
Err(
|
||||
EntityNotFound,
|
||||
)
|
7
tests/models/snapshots/can_find_by_pid@users-2.snap
Normal file
7
tests/models/snapshots/can_find_by_pid@users-2.snap
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: non_existing_user_results
|
||||
---
|
||||
Err(
|
||||
EntityNotFound,
|
||||
)
|
21
tests/models/snapshots/can_find_by_pid@users.snap
Normal file
21
tests/models/snapshots/can_find_by_pid@users.snap
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: existing_user
|
||||
---
|
||||
Ok(
|
||||
Model {
|
||||
created_at: 2023-11-12T12:34:56.789+00:00,
|
||||
updated_at: 2023-11-12T12:34:56.789+00:00,
|
||||
id: 1,
|
||||
pid: 11111111-1111-1111-1111-111111111111,
|
||||
email: "user1@example.com",
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
|
||||
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
|
||||
name: "user1",
|
||||
reset_token: None,
|
||||
reset_sent_at: None,
|
||||
email_verification_token: None,
|
||||
email_verification_sent_at: None,
|
||||
email_verified_at: None,
|
||||
},
|
||||
)
|
9
tests/models/snapshots/can_validate_model@users.snap
Normal file
9
tests/models/snapshots/can_validate_model@users.snap
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tests/models/main.rs
|
||||
expression: res
|
||||
---
|
||||
Err(
|
||||
Custom(
|
||||
"{\"email\":[{\"code\":\"invalid email\",\"message\":null}],\"name\":[{\"code\":\"length\",\"message\":\"Name must be at least 2 characters long.\"}]}",
|
||||
),
|
||||
)
|
@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tests/models/users.rs
|
||||
expression: new_user
|
||||
---
|
||||
Err(
|
||||
EntityAlreadyExists,
|
||||
)
|
223
tests/models/users.rs
Normal file
223
tests/models/users.rs
Normal file
@ -0,0 +1,223 @@
|
||||
use insta::assert_debug_snapshot;
|
||||
use loco_rs::{model::ModelError, testing};
|
||||
use daily_aujourdhui::{
|
||||
app::App,
|
||||
models::users::{self, Model, RegisterParams},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
||||
use serial_test::serial;
|
||||
|
||||
macro_rules! configure_insta {
|
||||
($($expr:expr),*) => {
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_prepend_module_to_snapshot(false);
|
||||
settings.set_snapshot_suffix("users");
|
||||
let _guard = settings.bind_to_scope();
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_can_validate_model() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = testing::boot_test::<App>().await.unwrap();
|
||||
|
||||
let res = users::ActiveModel {
|
||||
name: ActiveValue::set("1".to_string()),
|
||||
email: ActiveValue::set("invalid-email".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&boot.app_context.db)
|
||||
.await;
|
||||
|
||||
assert_debug_snapshot!(res);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_create_with_password() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = testing::boot_test::<App>().await.unwrap();
|
||||
|
||||
let params = RegisterParams {
|
||||
email: "test@framework.com".to_string(),
|
||||
password: "1234".to_string(),
|
||||
name: "framework".to_string(),
|
||||
};
|
||||
let res = Model::create_with_password(&boot.app_context.db, ¶ms).await;
|
||||
|
||||
insta::with_settings!({
|
||||
filters => testing::cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(res);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn handle_create_with_password_with_duplicate() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = testing::boot_test::<App>().await.unwrap();
|
||||
testing::seed::<App>(&boot.app_context.db).await.unwrap();
|
||||
|
||||
let new_user: Result<Model, ModelError> = Model::create_with_password(
|
||||
&boot.app_context.db,
|
||||
&RegisterParams {
|
||||
email: "user1@example.com".to_string(),
|
||||
password: "1234".to_string(),
|
||||
name: "framework".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_debug_snapshot!(new_user);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_find_by_email() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = testing::boot_test::<App>().await.unwrap();
|
||||
testing::seed::<App>(&boot.app_context.db).await.unwrap();
|
||||
|
||||
let existing_user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await;
|
||||
let non_existing_user_results =
|
||||
Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await;
|
||||
|
||||
assert_debug_snapshot!(existing_user);
|
||||
assert_debug_snapshot!(non_existing_user_results);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_find_by_pid() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = testing::boot_test::<App>().await.unwrap();
|
||||
testing::seed::<App>(&boot.app_context.db).await.unwrap();
|
||||
|
||||
let existing_user =
|
||||
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await;
|
||||
let non_existing_user_results =
|
||||
Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await;
|
||||
|
||||
assert_debug_snapshot!(existing_user);
|
||||
assert_debug_snapshot!(non_existing_user_results);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_verification_token() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = testing::boot_test::<App>().await.unwrap();
|
||||
testing::seed::<App>(&boot.app_context.db).await.unwrap();
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(user.email_verification_sent_at.is_none());
|
||||
assert!(user.email_verification_token.is_none());
|
||||
|
||||
assert!(user
|
||||
.into_active_model()
|
||||
.set_email_verification_sent(&boot.app_context.db)
|
||||
.await
|
||||
.is_ok());
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(user.email_verification_sent_at.is_some());
|
||||
assert!(user.email_verification_token.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_set_forgot_password_sent() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = testing::boot_test::<App>().await.unwrap();
|
||||
testing::seed::<App>(&boot.app_context.db).await.unwrap();
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(user.reset_sent_at.is_none());
|
||||
assert!(user.reset_token.is_none());
|
||||
|
||||
assert!(user
|
||||
.into_active_model()
|
||||
.set_forgot_password_sent(&boot.app_context.db)
|
||||
.await
|
||||
.is_ok());
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(user.reset_sent_at.is_some());
|
||||
assert!(user.reset_token.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_verified() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = testing::boot_test::<App>().await.unwrap();
|
||||
testing::seed::<App>(&boot.app_context.db).await.unwrap();
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(user.email_verified_at.is_none());
|
||||
|
||||
assert!(user
|
||||
.into_active_model()
|
||||
.verified(&boot.app_context.db)
|
||||
.await
|
||||
.is_ok());
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(user.email_verified_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_reset_password() {
|
||||
configure_insta!();
|
||||
|
||||
let boot = testing::boot_test::<App>().await.unwrap();
|
||||
testing::seed::<App>(&boot.app_context.db).await.unwrap();
|
||||
|
||||
let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(user.verify_password("12341234"));
|
||||
|
||||
assert!(user
|
||||
.clone()
|
||||
.into_active_model()
|
||||
.reset_password(&boot.app_context.db, "new-password")
|
||||
.await
|
||||
.is_ok());
|
||||
|
||||
assert!(
|
||||
Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111")
|
||||
.await
|
||||
.unwrap()
|
||||
.verify_password("new-password")
|
||||
);
|
||||
}
|
218
tests/requests/auth.rs
Normal file
218
tests/requests/auth.rs
Normal file
@ -0,0 +1,218 @@
|
||||
use insta::{assert_debug_snapshot, with_settings};
|
||||
use loco_rs::testing;
|
||||
use daily_aujourdhui::{app::App, models::users};
|
||||
use rstest::rstest;
|
||||
use serial_test::serial;
|
||||
|
||||
use super::prepare_data;
|
||||
|
||||
// TODO: see how to dedup / extract this to app-local test utils
|
||||
// not to framework, because that would require a runtime dep on insta
|
||||
macro_rules! configure_insta {
|
||||
($($expr:expr),*) => {
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_prepend_module_to_snapshot(false);
|
||||
settings.set_snapshot_suffix("auth_request");
|
||||
let _guard = settings.bind_to_scope();
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_register() {
|
||||
configure_insta!();
|
||||
|
||||
testing::request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
let _response = request.post("/api/auth/register").json(&payload).await;
|
||||
let saved_user = users::Model::find_by_email(&ctx.db, email).await;
|
||||
|
||||
with_settings!({
|
||||
filters => testing::cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(saved_user);
|
||||
});
|
||||
|
||||
with_settings!({
|
||||
filters => testing::cleanup_email()
|
||||
}, {
|
||||
assert_debug_snapshot!(ctx.mailer.unwrap().deliveries());
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("login_with_valid_password", "12341234")]
|
||||
#[case("login_with_invalid_password", "invalid-password")]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) {
|
||||
configure_insta!();
|
||||
|
||||
testing::request::<App, _, _>(|request, ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let register_payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": "12341234"
|
||||
});
|
||||
|
||||
//Creating a new user
|
||||
_ = request
|
||||
.post("/api/auth/register")
|
||||
.json(®ister_payload)
|
||||
.await;
|
||||
|
||||
let user = users::Model::find_by_email(&ctx.db, email).await.unwrap();
|
||||
let verify_payload = serde_json::json!({
|
||||
"token": user.email_verification_token,
|
||||
});
|
||||
request.post("/api/auth/verify").json(&verify_payload).await;
|
||||
|
||||
//verify user request
|
||||
let response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": email,
|
||||
"password": password
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Make sure email_verified_at is set
|
||||
assert!(users::Model::find_by_email(&ctx.db, email)
|
||||
.await
|
||||
.unwrap()
|
||||
.email_verified_at
|
||||
.is_some());
|
||||
|
||||
with_settings!({
|
||||
filters => testing::cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!(test_name, (response.status_code(), response.text()));
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_login_without_verify() {
|
||||
configure_insta!();
|
||||
|
||||
testing::request::<App, _, _>(|request, _ctx| async move {
|
||||
let email = "test@loco.com";
|
||||
let password = "12341234";
|
||||
let register_payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": email,
|
||||
"password": password
|
||||
});
|
||||
|
||||
//Creating a new user
|
||||
_ = request
|
||||
.post("/api/auth/register")
|
||||
.json(®ister_payload)
|
||||
.await;
|
||||
|
||||
//verify user request
|
||||
let response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": email,
|
||||
"password": password
|
||||
}))
|
||||
.await;
|
||||
|
||||
with_settings!({
|
||||
filters => testing::cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!((response.status_code(), response.text()));
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_reset_password() {
|
||||
configure_insta!();
|
||||
|
||||
testing::request::<App, _, _>(|request, ctx| async move {
|
||||
let login_data = prepare_data::init_user_login(&request, &ctx).await;
|
||||
|
||||
let forgot_payload = serde_json::json!({
|
||||
"email": login_data.user.email,
|
||||
});
|
||||
_ = request.post("/api/auth/forgot").json(&forgot_payload).await;
|
||||
|
||||
let user = users::Model::find_by_email(&ctx.db, &login_data.user.email)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(user.reset_token.is_some());
|
||||
assert!(user.reset_sent_at.is_some());
|
||||
|
||||
let new_password = "new-password";
|
||||
let reset_payload = serde_json::json!({
|
||||
"token": user.reset_token,
|
||||
"password": new_password,
|
||||
});
|
||||
|
||||
let reset_response = request.post("/api/auth/reset").json(&reset_payload).await;
|
||||
|
||||
let user = users::Model::find_by_email(&ctx.db, &user.email)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(user.reset_token.is_none());
|
||||
assert!(user.reset_sent_at.is_none());
|
||||
|
||||
assert_debug_snapshot!((reset_response.status_code(), reset_response.text()));
|
||||
|
||||
let response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": user.email,
|
||||
"password": new_password
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status_code(), 200);
|
||||
|
||||
with_settings!({
|
||||
filters => testing::cleanup_email()
|
||||
}, {
|
||||
assert_debug_snapshot!(ctx.mailer.unwrap().deliveries());
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn can_get_current_user() {
|
||||
configure_insta!();
|
||||
|
||||
testing::request::<App, _, _>(|request, ctx| async move {
|
||||
let user = prepare_data::init_user_login(&request, &ctx).await;
|
||||
|
||||
let (auth_key, auth_value) = prepare_data::auth_header(&user.token);
|
||||
let response = request
|
||||
.get("/api/auth/current")
|
||||
.add_header(auth_key, auth_value)
|
||||
.await;
|
||||
|
||||
with_settings!({
|
||||
filters => testing::cleanup_user_model()
|
||||
}, {
|
||||
assert_debug_snapshot!((response.status_code(), response.text()));
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}
|
2
tests/requests/mod.rs
Normal file
2
tests/requests/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
mod auth;
|
||||
mod prepare_data;
|
57
tests/requests/prepare_data.rs
Normal file
57
tests/requests/prepare_data.rs
Normal file
@ -0,0 +1,57 @@
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use loco_rs::{app::AppContext, TestServer};
|
||||
use daily_aujourdhui::{models::users, views::auth::LoginResponse};
|
||||
|
||||
const USER_EMAIL: &str = "test@loco.com";
|
||||
const USER_PASSWORD: &str = "1234";
|
||||
|
||||
pub struct LoggedInUser {
|
||||
pub user: users::Model,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn init_user_login(request: &TestServer, ctx: &AppContext) -> LoggedInUser {
|
||||
let register_payload = serde_json::json!({
|
||||
"name": "loco",
|
||||
"email": USER_EMAIL,
|
||||
"password": USER_PASSWORD
|
||||
});
|
||||
|
||||
//Creating a new user
|
||||
request
|
||||
.post("/api/auth/register")
|
||||
.json(®ister_payload)
|
||||
.await;
|
||||
let user = users::Model::find_by_email(&ctx.db, USER_EMAIL)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let verify_payload = serde_json::json!({
|
||||
"token": user.email_verification_token,
|
||||
});
|
||||
|
||||
request.post("/api/auth/verify").json(&verify_payload).await;
|
||||
|
||||
let response = request
|
||||
.post("/api/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"email": USER_EMAIL,
|
||||
"password": USER_PASSWORD
|
||||
}))
|
||||
.await;
|
||||
|
||||
let login_response: LoginResponse = serde_json::from_str(&response.text()).unwrap();
|
||||
|
||||
LoggedInUser {
|
||||
user: users::Model::find_by_email(&ctx.db, USER_EMAIL)
|
||||
.await
|
||||
.unwrap(),
|
||||
token: login_response.token,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_header(token: &str) -> (HeaderName, HeaderValue) {
|
||||
let auth_header_value = HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap();
|
||||
|
||||
(HeaderName::from_static("authorization"), auth_header_value)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user