Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: saas example #50

Merged
merged 1 commit into from
May 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions fullstack-templates/saas/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next"
}
35 changes: 35 additions & 0 deletions fullstack-templates/saas/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
Binary file added fullstack-templates/saas/Mainpage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions fullstack-templates/saas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## Shuttle SaaS Example
### Introduction
This repo is meant to serve as a SaaS template with a Next.js Typescript frontend and a Rust backend. The design of the template internally is based on a sales-oriented Customer Relationship Management (CRM) tool where users will be able to view their customers, sales records as well as some analytics.

### Features
- Take subscription payments with Stripe
- Email session-based login
- Mailgun (email subscription, welcome email etc)
- Pre-configured frontend routes for easy transition
- Examples of how to implement simple dashboard analytics

### Pre-requisites

* Rust

* Node.js/NPM.

* Typescript.

* [cargo-shuttle](https://www.shuttle.rs)

### Instructions for Usage

* Fork or clone the repo, then navigate to the folder where you cloned the repo:

```
git clone https://github.com/joshua-mo-143/shuttle-saas-example.git
cd shuttle-saas-example
```

* Run `npm i` to install the dependencies on the frontend.

* Set your secrets in the Secrets.toml file at the `Cargo.toml` level of your backend folder (any that are unset will default to "None" to stop the web service from automatically crashing but some services may not work!)

* Run `npm run dev` and go to [http://localhost:8000](http://localhost:8000) once the app has built and you should see the following:

![Main page for Next.js + Shuttle Saas Template](./Mainpage.png)

### Troubleshooting

* If you change the migrations after running locally or deploying, you will need to go into the database itself and delete the tables. You can do this easily with something like [psql](https://www.postgresql.org/docs/current/app-psql.html) or [pgAdmin](https://www.pgadmin.org/).

* If connecting to external services like Stripe doesn't work, try checking your Secrets.toml file.

* Shuttle connects by default to port 8000 - if you're currently already using something at port 8000, you can add the `--port <port-number>` to the `cargo shuttle run` command to change this.
6 changes: 6 additions & 0 deletions fullstack-templates/saas/backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
target/
Shuttle.toml
Secrets.toml
Secrets.dev.toml
test_sub.sh
public/
28 changes: 28 additions & 0 deletions fullstack-templates/saas/backend/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "api"
version = "0.1.0"
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
async-stripe = { version = "0.21.0", features = ["runtime-tokio-hyper"] }
axum = "0.6.15"
axum-extra = { version = "0.7.3", features = ["cookie-private"] }
axum-macros = "0.3.7"
bcrypt = "0.14.0"
http = "0.2.9"
lettre = "0.10.4"
rand = "0.8.5"
reqwest = "0.11.16"
serde = { version = "1.0.160", features = ["derive"] }
shuttle-axum = "0.16.0"
shuttle-runtime = "0.16.0"
shuttle-secrets = "0.16.0"
shuttle-shared-db = { version = "0.16.0", features = ["postgres"] }
shuttle-static-folder = "0.16.0"
sqlx = { version = "0.6.3", features = ["runtime-tokio-native-tls", "postgres", "time"] }
time = { version = "0.3.20", features = ["serde"] }
tokio = "1.27.0"
tower = "0.4.13"
tower-http = { version = "0.4.0", features = ["cors", "fs"] }
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
password VARCHAR NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
ROLE VARCHAR NOT NULL DEFAULT 'user'
);

CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
session_id VARCHAR NOT NULL UNIQUE,
user_id int NOT NULL UNIQUE,
expires TIMESTAMP WITH TIME ZONE,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
firstName VARCHAR NOT NULL,
lastName VARCHAR NOT NULL,
email VARCHAR NOT NULL,
phone VARCHAR(14) NOT NULL,
priority SMALLINT NOT NULL CHECK (priority >= 1 AND priority <= 5),
owner_id int NOT NULL,
is_archived BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT FK_customer FOREIGN KEY(owner_id) REFERENCES users(id)
);

CREATE TABLE IF NOT EXISTS deals (
id SERIAL PRIMARY KEY,
estimate_worth INT,
actual_worth INT,
status VARCHAR NOT NULL,
closed VARCHAR NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
customer_id int NOT NULL,
owner_id int NOT NULL,
CONSTRAINT FK_deal FOREIGN KEY(owner_id) REFERENCES users(id),
CONSTRAINT FK_owner FOREIGN KEY(owner_id) REFERENCES users(id),
is_archived BOOLEAN DEFAULT FALSE
);

CREATE TABLE IF NOT EXISTS apikeys (
id SERIAL PRIMARY KEY,
api_key VARCHAR NOT NULL,
owner_id int,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_used TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT FK_customer FOREIGN KEY (owner_id) REFERENCES users(id)
);
131 changes: 131 additions & 0 deletions fullstack-templates/saas/backend/src/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use axum::middleware::Next;
use axum::{
extract::State,
http::{Request, StatusCode},
response::{IntoResponse, Response},
Json,
};
use axum_extra::extract::cookie::{Cookie, PrivateCookieJar, SameSite};
use serde::Deserialize;
use sqlx::Row;
use time::Duration;

use crate::AppState;

#[derive(Deserialize)]
pub struct RegisterDetails {
name: String,
email: String,
password: String,
}

#[derive(Deserialize)]
pub struct LoginDetails {
email: String,
password: String,
}

pub async fn register(
State(state): State<AppState>,
Json(newuser): Json<RegisterDetails>,
) -> impl IntoResponse {
let hashed_password = bcrypt::hash(newuser.password, 10).unwrap();

let query = sqlx::query("INSERT INTO users (name, email, password) values ($1, $2, $3)")
.bind(newuser.name)
.bind(newuser.email)
.bind(hashed_password)
.execute(&state.postgres);

match query.await {
Ok(_) => (StatusCode::CREATED, "Account created!".to_string()).into_response(),
Err(e) => (
StatusCode::BAD_REQUEST,
format!("Something went wrong: {e}"),
)
.into_response(),
}
}

pub async fn login(
State(state): State<AppState>,
jar: PrivateCookieJar,
Json(login): Json<LoginDetails>,
) -> Result<(PrivateCookieJar, StatusCode), StatusCode> {
let query = sqlx::query("SELECT * FROM users WHERE email = $1")
.bind(&login.email)
.fetch_one(&state.postgres);

match query.await {
Ok(res) => {
if bcrypt::verify(login.password, res.get("password")).is_err() {
return Err(StatusCode::BAD_REQUEST);
}
let session_id = rand::random::<u64>().to_string();

sqlx::query("INSERT INTO sessions (session_id, user_id) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET session_id = EXCLUDED.session_id")
.bind(&session_id)
.bind(res.get::<i32, _>("id"))
.execute(&state.postgres)
.await
.expect("Couldn't insert session :(");

let cookie = Cookie::build("foo", session_id)
.secure(true)
.same_site(SameSite::Strict)
.http_only(true)
.path("/")
.max_age(Duration::WEEK)
.finish();

Ok((jar.add(cookie), StatusCode::OK))
}

Err(_) => Err(StatusCode::BAD_REQUEST),
}
}

pub async fn logout(
State(state): State<AppState>,
jar: PrivateCookieJar,
) -> Result<PrivateCookieJar, StatusCode> {
let Some(cookie) = jar.get("sessionid").map(|cookie| cookie.value().to_owned()) else {
return Ok(jar)
};

let query = sqlx::query("DELETE FROM sessions WHERE session_id = $1")
.bind(cookie)
.execute(&state.postgres);

match query.await {
Ok(_) => Ok(jar.remove(Cookie::named("foo"))),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}

pub async fn validate_session<B>(
jar: PrivateCookieJar,
State(state): State<AppState>,
request: Request<B>,
next: Next<B>,
) -> (PrivateCookieJar, Response) {
let Some(cookie) = jar.get("foo").map(|cookie| cookie.value().to_owned()) else {

println!("Couldn't find a cookie in the jar");
return (jar,(StatusCode::FORBIDDEN, "Forbidden!".to_string()).into_response())
};

let find_session =
sqlx::query("SELECT * FROM sessions WHERE session_id = $1 AND expires > CURRENT_TIMESTAMP")
.bind(cookie)
.execute(&state.postgres)
.await;

match find_session {
Ok(_) => (jar, next.run(request).await),
Err(_) => (
jar,
(StatusCode::FORBIDDEN, "Forbidden!".to_string()).into_response(),
),
}
}
Loading