From fad69110946409e396e78f9b573039d061ce16af Mon Sep 17 00:00:00 2001 From: skinkade Date: Thu, 11 Jul 2024 13:48:02 -0400 Subject: [PATCH] Initial pending user (role) / invite functionality --- src/wisp_multitenant_demo.gleam | 2 +- .../models/pending_user.gleam | 175 ++++++++++++++++++ .../models/pending_user_tenant_role.gleam | 160 ++++++++++++++++ ...user_role.gleam => user_tenant_role.gleam} | 40 ++-- src/wisp_multitenant_demo/types/time.gleam | 8 + .../web/middleware.gleam | 7 +- src/wisp_multitenant_demo/web/web.gleam | 6 +- 7 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 src/wisp_multitenant_demo/models/pending_user.gleam create mode 100644 src/wisp_multitenant_demo/models/pending_user_tenant_role.gleam rename src/wisp_multitenant_demo/models/{tenant_user_role.gleam => user_tenant_role.gleam} (79%) diff --git a/src/wisp_multitenant_demo.gleam b/src/wisp_multitenant_demo.gleam index 2a3394e..5a359f0 100644 --- a/src/wisp_multitenant_demo.gleam +++ b/src/wisp_multitenant_demo.gleam @@ -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" } diff --git a/src/wisp_multitenant_demo/models/pending_user.gleam b/src/wisp_multitenant_demo/models/pending_user.gleam new file mode 100644 index 0000000..2320584 --- /dev/null +++ b/src/wisp_multitenant_demo/models/pending_user.gleam @@ -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)) +} diff --git a/src/wisp_multitenant_demo/models/pending_user_tenant_role.gleam b/src/wisp_multitenant_demo/models/pending_user_tenant_role.gleam new file mode 100644 index 0000000..3784f8a --- /dev/null +++ b/src/wisp_multitenant_demo/models/pending_user_tenant_role.gleam @@ -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) +} diff --git a/src/wisp_multitenant_demo/models/tenant_user_role.gleam b/src/wisp_multitenant_demo/models/user_tenant_role.gleam similarity index 79% rename from src/wisp_multitenant_demo/models/tenant_user_role.gleam rename to src/wisp_multitenant_demo/models/user_tenant_role.gleam index 42b1681..3aa79c5 100644 --- a/src/wisp_multitenant_demo/models/tenant_user_role.gleam +++ b/src/wisp_multitenant_demo/models/user_tenant_role.gleam @@ -4,13 +4,13 @@ import gleam/result import wisp_multitenant_demo/models/tenant.{type TenantId} import wisp_multitenant_demo/models/user.{type UserId} -pub type TenantUserRole { +pub type UserTenantRole { TenantOwner TenantAdmin TenantMember } -pub fn role_to_string(role: TenantUserRole) -> String { +pub fn role_to_string(role: UserTenantRole) -> String { case role { TenantMember -> "member" TenantAdmin -> "admin" @@ -18,7 +18,7 @@ pub fn role_to_string(role: TenantUserRole) -> String { } } -pub fn role_from_string(str: String) -> Result(TenantUserRole, Nil) { +pub fn role_from_string(str: String) -> Result(UserTenantRole, Nil) { case str { "member" -> Ok(TenantMember) "admin" -> Ok(TenantAdmin) @@ -34,19 +34,19 @@ pub fn decode_role(d: Dynamic) { Ok(role) } -pub fn set_tenant_user_role( +pub fn set_user_tenant_role( db: Connection, - tenant_id: TenantId, user_id: UserId, - role: TenantUserRole, + tenant_id: TenantId, + role: UserTenantRole, ) -> Result(Nil, QueryError) { let sql = " - INSERT INTO tenant_user_roles - (tenant_id, user_id, role) + INSERT INTO user_tenant_roles + (user_id, tenant_id, role) VALUES ($1, $2, $3) - ON CONFLICT (tenant_id, user_id) + ON CONFLICT (user_id, tenant_id) DO UPDATE SET role = $3; " @@ -55,8 +55,8 @@ pub fn set_tenant_user_role( sql, db, [ - tenant_id |> tenant.id_to_int() |> pgo.int(), user_id |> user.id_to_int() |> pgo.int(), + tenant_id |> tenant.id_to_int() |> pgo.int(), role |> role_to_string() |> pgo.text(), ], dynamic.dynamic, @@ -74,8 +74,8 @@ pub fn remove_tenant_user_role( let sql = " DELETE FROM tenant_user_roles - WHERE tenant_id = $1 - AND user_id = $2; + WHERE user_id = $1 + AND tenant_id = $2; " use _ <- result.try({ @@ -83,8 +83,8 @@ pub fn remove_tenant_user_role( sql, db, [ - tenant_id |> tenant.id_to_int() |> pgo.int(), user_id |> user.id_to_int() |> pgo.int(), + tenant_id |> tenant.id_to_int() |> pgo.int(), ], dynamic.dynamic, ) @@ -93,18 +93,18 @@ pub fn remove_tenant_user_role( Ok(Nil) } -pub type UserTenantRole { - UserTenantRole( +pub type UserTenantRoleForAccess { + UserTenantRoleForAccess( tenant_id: TenantId, tenant_full_name: String, - role: TenantUserRole, + role: UserTenantRole, ) } pub fn decode_assigned_role(d: Dynamic) { let decoder = dynamic.decode3( - UserTenantRole, + UserTenantRoleForAccess, dynamic.element(0, tenant.decode_tenant_id), dynamic.element(1, dynamic.string), dynamic.element(2, decode_role), @@ -116,16 +116,16 @@ pub fn decode_assigned_role(d: Dynamic) { pub fn get_user_tenant_roles( db: Connection, user_id: UserId, -) -> Result(List(UserTenantRole), QueryError) { +) -> Result(List(UserTenantRoleForAccess), QueryError) { let sql = " SELECT tur.tenant_id, t.full_name, tur.role - FROM tenant_user_roles tur + FROM user_tenant_roles utr JOIN tenants t - ON tur.tenant_id = t.id + ON utr.tenant_id = t.id WHERE user_id = $1; " diff --git a/src/wisp_multitenant_demo/types/time.gleam b/src/wisp_multitenant_demo/types/time.gleam index 313c587..17c3ca6 100644 --- a/src/wisp_multitenant_demo/types/time.gleam +++ b/src/wisp_multitenant_demo/types/time.gleam @@ -1,5 +1,6 @@ import birl import gleam/dynamic.{type Dynamic, DecodeError} +import gleam/order import gleam/result pub fn dynamic_time(d: Dynamic) { @@ -11,3 +12,10 @@ pub fn dynamic_time(d: Dynamic) { Ok(time) -> Ok(time) } } + +pub fn is_passed(timestamp: birl.Time) -> Bool { + case birl.compare(birl.utc_now(), timestamp) { + order.Gt -> True + _ -> False + } +} diff --git a/src/wisp_multitenant_demo/web/middleware.gleam b/src/wisp_multitenant_demo/web/middleware.gleam index 70da076..9b8506a 100644 --- a/src/wisp_multitenant_demo/web/middleware.gleam +++ b/src/wisp_multitenant_demo/web/middleware.gleam @@ -6,9 +6,9 @@ import gleam/pgo import gleam/result import wisp.{type Request, type Response} import wisp_multitenant_demo/models/tenant -import wisp_multitenant_demo/models/tenant_user_role import wisp_multitenant_demo/models/user import wisp_multitenant_demo/models/user_session +import wisp_multitenant_demo/models/user_tenant_role import wisp_multitenant_demo/web/web pub fn derive_session( @@ -53,12 +53,13 @@ pub fn derive_user( pub fn derive_user_tenant_roles( conn: pgo.Connection, user: Option(user.User), - handler: fn(Option(List(tenant_user_role.UserTenantRole))) -> Response, + handler: fn(Option(List(user_tenant_role.UserTenantRoleForAccess))) -> + Response, ) -> Response { use <- bool.guard(option.is_none(user), handler(None)) let assert Some(user) = user - case tenant_user_role.get_user_tenant_roles(conn, user.id) { + case user_tenant_role.get_user_tenant_roles(conn, user.id) { Error(_) -> wisp.internal_server_error() Ok(roles) -> handler(Some(roles)) } diff --git a/src/wisp_multitenant_demo/web/web.gleam b/src/wisp_multitenant_demo/web/web.gleam index a2f78bf..b2cf58f 100644 --- a/src/wisp_multitenant_demo/web/web.gleam +++ b/src/wisp_multitenant_demo/web/web.gleam @@ -1,8 +1,10 @@ import gleam/option.{type Option} import gleam/pgo import wisp -import wisp_multitenant_demo/models/tenant_user_role.{type UserTenantRole} import wisp_multitenant_demo/models/user +import wisp_multitenant_demo/models/user_tenant_role.{ + type UserTenantRoleForAccess, +} pub type AppContext { AppContext(db: pgo.Connection, static_directory: String) @@ -13,7 +15,7 @@ pub type AppContext { pub type RequestContext { RequestContext( user: Option(user.User), - user_tenant_roles: Option(List(UserTenantRole)), + user_tenant_roles: Option(List(UserTenantRoleForAccess)), ) }