Skip to content

Commit

Permalink
Initial pending user (role) / invite functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
skinkade committed Jul 11, 2024
1 parent 29812a2 commit fad6911
Show file tree
Hide file tree
Showing 7 changed files with 372 additions and 26 deletions.
2 changes: 1 addition & 1 deletion src/wisp_multitenant_demo.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ pub fn main() {
}

pub fn static_directory() -> String {
let assert Ok(priv_directory) = wisp.priv_directory("wisp_auth_example")
let assert Ok(priv_directory) = wisp.priv_directory("wisp_multitenant_demo")
priv_directory <> "/static"
}
175 changes: 175 additions & 0 deletions src/wisp_multitenant_demo/models/pending_user.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import birl.{type Time}
import birl/duration
import gleam/bit_array
import gleam/bool
import gleam/crypto
import gleam/dynamic.{type Dynamic}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/pgo.{type Connection}
import gleam/result
import wisp
import wisp_multitenant_demo/models/pending_user_tenant_role
import wisp_multitenant_demo/models/user
import wisp_multitenant_demo/models/user_tenant_role
import wisp_multitenant_demo/types/email.{type Email}
import wisp_multitenant_demo/types/password.{type Password}
import wisp_multitenant_demo/types/time

pub type PendingUser {
PendingUser(email_address: Email, invited_at: Time, expires_at: Time)
}

pub fn decode_pending_user_sql(d: Dynamic) {
let decoder =
dynamic.decode3(
PendingUser,
dynamic.element(0, email.decode_email),
dynamic.element(1, time.dynamic_time),
dynamic.element(2, time.dynamic_time),
)

decoder(d)
}

pub type PendingUserToken {
PendingUserToken(value: String)
}

const default_invite_duration_minutes = 15

pub fn create(
db: Connection,
email: Email,
) -> Result(PendingUserToken, pgo.QueryError) {
let sql =
"
INSERT INTO pending_users
(email_address, token_hash, expires_at)
VALUES
($1, $2, $3)
ON CONFLICT (email_address)
DO UPDATE SET
token_hash = $2,
expires_at = $3;
"

let invite_token = wisp.random_string(32)
let token_hash =
crypto.hash(crypto.Sha256, invite_token |> bit_array.from_string())

let now = birl.utc_now()
let expiration =
now |> birl.add(duration.minutes(default_invite_duration_minutes))

use _ <- result.try({
pgo.execute(
sql,
db,
[
email |> email.to_string() |> pgo.text(),
token_hash |> pgo.bytea(),
expiration |> birl.to_erlang_universal_datetime() |> pgo.timestamp(),
],
dynamic.dynamic,
)
})

Ok(PendingUserToken(invite_token))
}

pub fn remove_invite_by_email(
db: Connection,
email: email.Email,
) -> Result(Nil, pgo.QueryError) {
let sql =
"
DELETE FROM pending_users
WHERE email_address = $1;
"

use _ <- result.try({
pgo.execute(
sql,
db,
[email |> email.to_string() |> pgo.text()],
dynamic.dynamic,
)
})

Ok(Nil)
}

pub fn get_active_invite_by_token(
conn: Connection,
invite_token: String,
) -> Result(Option(PendingUser), pgo.QueryError) {
let hash = crypto.hash(crypto.Sha256, bit_array.from_string(invite_token))

let sql =
"
SELECT
email_address,
invited_at::text,
expires_at::text
FROM pending_users
WHERE invite_token_hash = $1
AND expires_at > now()
"

use result <- result.try({
pgo.execute(sql, conn, [hash |> pgo.bytea()], decode_pending_user_sql)
})

case result.rows {
[pending_user] -> Ok(Some(pending_user))
_ -> Ok(None)
}
}

pub fn try_redeem_invite(
conn: Connection,
invite_token: String,
password: password.Password,
) -> Result(Option(user.User), pgo.TransactionError) {
use conn <- pgo.transaction(conn)

let assert Ok(pending) = get_active_invite_by_token(conn, invite_token)

use <- bool.guard(option.is_none(pending), Ok(None))
let assert Some(pending) = pending

// TODO: handle if user with this email already exists
let assert Ok(user) = user.create(conn, pending.email_address, password)
let assert Ok(Nil) = remove_invite_by_email(conn, pending.email_address)

let assert Ok(pending_roles) =
pending_user_tenant_role.get_pending_roles_by_email(
conn,
user.email_address,
)
case list.is_empty(pending_roles) {
True -> Nil
False -> {
// TODO optimize
list.each(pending_roles, fn(role) {
let assert Ok(Nil) =
user_tenant_role.set_user_tenant_role(
conn,
user.id,
role.tenant_id,
role.role,
)
})

let assert Ok(Nil) =
pending_user_tenant_role.delete_pending_roles_by_email(
conn,
user.email_address,
)
Nil
}
}

Ok(Some(user))
}
160 changes: 160 additions & 0 deletions src/wisp_multitenant_demo/models/pending_user_tenant_role.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import birl.{type Time}
import birl/duration
import gleam/bit_array
import gleam/bool
import gleam/crypto
import gleam/dynamic.{type Dynamic}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/pgo.{type Connection}
import gleam/result
import wisp
import wisp_multitenant_demo/models/tenant
import wisp_multitenant_demo/models/user
import wisp_multitenant_demo/models/user_tenant_role.{type UserTenantRole}
import wisp_multitenant_demo/types/email.{type Email}
import wisp_multitenant_demo/types/password.{type Password}
import wisp_multitenant_demo/types/time

pub type PendingUserTenantRole {
PendingUserTenantRole(
email_address: Email,
tenant_id: tenant.TenantId,
role: user_tenant_role.UserTenantRole,
)
}

pub fn decode_pending_user_sql(d: Dynamic) {
let decoder =
dynamic.decode3(
PendingUserTenantRole,
dynamic.element(0, email.decode_email),
dynamic.element(1, tenant.decode_tenant_id),
dynamic.element(2, user_tenant_role.decode_role),
)

decoder(d)
}

pub type PendingUserToken {
PendingUserToken(value: String)
}

pub fn create_pending_user_tenant_role(
db: Connection,
email: Email,
tenant_id: tenant.TenantId,
role: UserTenantRole,
) -> Result(Nil, pgo.QueryError) {
let sql =
"
INSERT INTO pending_user_tenant_roles
(email_address, tenant_id, role)
VALUES
($1, $2, $3)
ON CONFLICT (email_address, tenant_id)
DO UPDATE SET role = $3;
"

use _ <- result.try({
pgo.execute(
sql,
db,
[
email |> email.to_string() |> pgo.text(),
tenant_id |> tenant.id_to_int() |> pgo.int(),
role |> user_tenant_role.role_to_string() |> pgo.text(),
],
dynamic.dynamic,
)
})

Ok(Nil)
}

pub fn delete_pending_roles_by_email_and_tenant(
db: Connection,
email: Email,
tenant_id: tenant.TenantId,
) -> Result(Nil, pgo.QueryError) {
let sql =
"
DELETE FROM pending_user_tenant_roles
WHERE email_address = $1
AND tenant_id = $2;
"

use _ <- result.try({
pgo.execute(
sql,
db,
[
email |> email.to_string() |> pgo.text(),
tenant_id |> tenant.id_to_int() |> pgo.int(),
],
dynamic.dynamic,
)
})

Ok(Nil)
}

pub fn delete_pending_roles_by_email(
db: Connection,
email: Email,
) -> Result(Nil, pgo.QueryError) {
let sql =
"
DELETE FROM pending_user_tenant_roles
WHERE email_address = $1;
"

use _ <- result.try({
pgo.execute(
sql,
db,
[email |> email.to_string() |> pgo.text()],
dynamic.dynamic,
)
})

Ok(Nil)
}

pub type PendingTenantRole {
PendingTenantRole(tenant_id: tenant.TenantId, role: UserTenantRole)
}

pub fn decode_pending_tenant_role(d: Dynamic) {
let decoder =
dynamic.decode2(
PendingTenantRole,
dynamic.element(0, tenant.decode_tenant_id),
dynamic.element(1, user_tenant_role.decode_role),
)

decoder(d)
}

pub fn get_pending_roles_by_email(
db: Connection,
email: Email,
) -> Result(List(PendingTenantRole), pgo.QueryError) {
let sql =
"
SELECT tenant_id, role
FROM pending_user_tenant_roles
WHERE email_address = $1;
"

use result <- result.try({
pgo.execute(
sql,
db,
[email |> email.to_string() |> pgo.text()],
decode_pending_tenant_role,
)
})

Ok(result.rows)
}
Loading

0 comments on commit fad6911

Please sign in to comment.