1
0
Fork 0
mirror of synced 2024-08-09 07:08:01 +12:00

Merge pull request #12346 from Budibase/bug/budi-7008-i-was-able-to-send-two-invitations-to-the-same-user-email-2

Prevent sending invites to users who have already been invited.
This commit is contained in:
Sam Rose 2023-11-17 14:18:45 +00:00 committed by GitHub
commit 7412fd5a5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 301 additions and 248 deletions

View file

@ -50,6 +50,7 @@ export * from "./constants"
// expose package init function // expose package init function
import * as db from "./db" import * as db from "./db"
export const init = (opts: any = {}) => { export const init = (opts: any = {}) => {
db.init(opts.db) db.init(opts.db)
} }

View file

@ -4,3 +4,5 @@ export { default as Client } from "./redis"
export * as utils from "./utils" export * as utils from "./utils"
export * as clients from "./init" export * as clients from "./init"
export * as locks from "./redlockImpl" export * as locks from "./redlockImpl"
export * as invite from "./invite"
export * as passwordReset from "./passwordReset"

View file

@ -0,0 +1,91 @@
import { utils, tenancy, redis } from "../"
import env from "../environment"
const TTL_SECONDS = 60 * 60 * 24 * 7
interface Invite {
email: string
info: any
}
interface InviteWithCode extends Invite {
code: string
}
let client: redis.Client
export async function init() {
if (!client) {
client = new redis.Client(redis.utils.Databases.INVITATIONS)
}
return client
}
export async function shutdown() {
if (client) await client.finish()
}
/**
* Given an invite code and invite body, allow the update an existing/valid invite in redis
* @param inviteCode The invite code for an invite in redis
* @param value The body of the updated user invitation
*/
export async function updateCode(code: string, value: Invite) {
await client.store(code, value, TTL_SECONDS)
}
/**
* Generates an invitation code and writes it to redis - which can later be checked for user creation.
* @param email the email address which the code is being sent to (for use later).
* @param info Information to be carried along with the invitation.
* @return returns the code that was stored to redis.
*/
export async function createCode(email: string, info: any): Promise<string> {
const code = utils.newid()
await client.store(code, { email, info }, TTL_SECONDS)
return code
}
/**
* Checks that the provided invite code is valid - will return the email address of user that was invited.
* @param inviteCode the invite code that was provided as part of the link.
* @return If the code is valid then an email address will be returned.
*/
export async function getCode(code: string): Promise<Invite> {
const value = (await client.get(code)) as Invite | undefined
if (!value) {
throw "Invitation is not valid or has expired, please request a new one."
}
return value
}
export async function deleteCode(code: string) {
await client.delete(code)
}
/**
Get all currently available user invitations for the current tenant.
**/
export async function getInviteCodes(): Promise<InviteWithCode[]> {
const invites: { key: string; value: Invite }[] = await client.scan()
const results: InviteWithCode[] = invites.map(invite => {
return {
...invite.value,
code: invite.key,
}
})
if (!env.MULTI_TENANCY) {
return results
}
const tenantId = tenancy.getTenantId()
return results.filter(invite => tenantId === invite.info.tenantId)
}
export async function getExistingInvites(
emails: string[]
): Promise<InviteWithCode[]> {
return (await getInviteCodes()).filter(invite =>
emails.includes(invite.email)
)
}

View file

@ -0,0 +1,47 @@
import { redis, utils } from "../"
const TTL_SECONDS = 60 * 60
interface PasswordReset {
userId: string
info: any
}
let client: redis.Client
export async function init() {
if (!client) {
client = new redis.Client(redis.utils.Databases.PW_RESETS)
}
return client
}
export async function shutdown() {
if (client) await client.finish()
}
/**
* Given a user ID this will store a code (that is returned) for an hour in redis.
* The user can then return this code for resetting their password (through their reset link).
* @param userId the ID of the user which is to be reset.
* @param info Info about the user/the reset process.
* @return returns the code that was stored to redis.
*/
export async function createCode(userId: string, info: any): Promise<string> {
const code = utils.newid()
await client.store(code, { userId, info }, TTL_SECONDS)
return code
}
/**
* Given a reset code this will lookup to redis, check if the code is valid.
* @param code The code provided via the email link.
* @return returns the user ID if it is found
*/
export async function getCode(code: string): Promise<PasswordReset> {
const value = (await client.get(code)) as PasswordReset | undefined
if (!value) {
throw "Provided information is not valid, cannot reset password - please try again."
}
return value
}

View file

@ -303,7 +303,7 @@ export class UserDB {
static async bulkCreate( static async bulkCreate(
newUsersRequested: User[], newUsersRequested: User[],
groups: string[] groups?: string[]
): Promise<BulkUserCreated> { ): Promise<BulkUserCreated> {
const tenantId = getTenantId() const tenantId = getTenantId()
@ -328,7 +328,7 @@ export class UserDB {
}) })
continue continue
} }
newUser.userGroups = groups newUser.userGroups = groups || []
newUsers.push(newUser) newUsers.push(newUser)
if (isCreator(newUser)) { if (isCreator(newUser)) {
newCreators.push(newUser) newCreators.push(newUser)

View file

@ -6,6 +6,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import * as dbUtils from "../db" import * as dbUtils from "../db"
import { ViewName } from "../constants" import { ViewName } from "../constants"
import { getExistingInvites } from "../redis/invite"
/** /**
* Apply a system-wide search on emails: * Apply a system-wide search on emails:
@ -26,6 +27,9 @@ export async function searchExistingEmails(emails: string[]) {
const existingAccounts = await getExistingAccounts(emails) const existingAccounts = await getExistingAccounts(emails)
matchedEmails.push(...existingAccounts.map(account => account.email)) matchedEmails.push(...existingAccounts.map(account => account.email))
const invitedEmails = await getExistingInvites(emails)
matchedEmails.push(...invitedEmails.map(invite => invite.email))
return [...new Set(matchedEmails.map(email => email.toLowerCase()))] return [...new Set(matchedEmails.map(email => email.toLowerCase()))]
} }

View file

@ -12,7 +12,7 @@ import { generator } from "./generator"
import { tenant } from "." import { tenant } from "."
export const newEmail = () => { export const newEmail = () => {
return `${uuid()}@test.com` return `${uuid()}@example.com`
} }
export const user = (userProps?: Partial<Omit<User, "userId">>): User => { export const user = (userProps?: Partial<Omit<User, "userId">>): User => {

View file

@ -10,6 +10,7 @@ export interface SaveUserResponse {
export interface UserDetails { export interface UserDetails {
_id: string _id: string
email: string email: string
password?: string
} }
export interface BulkUserRequest { export interface BulkUserRequest {
@ -49,6 +50,7 @@ export type InviteUsersRequest = InviteUserRequest[]
export interface InviteUsersResponse { export interface InviteUsersResponse {
successful: { email: string }[] successful: { email: string }[]
unsuccessful: { email: string; reason: string }[] unsuccessful: { email: string; reason: string }[]
created?: boolean
} }
export interface SearchUsersRequest { export interface SearchUsersRequest {

View file

@ -1,8 +1,4 @@
import { import { redis } from "@budibase/backend-core"
checkInviteCode,
getInviteCodes,
updateInviteCode,
} from "../../../utilities/redis"
import * as userSdk from "../../../sdk/users" import * as userSdk from "../../../sdk/users"
import env from "../../../environment" import env from "../../../environment"
import { import {
@ -16,6 +12,7 @@ import {
Ctx, Ctx,
InviteUserRequest, InviteUserRequest,
InviteUsersRequest, InviteUsersRequest,
InviteUsersResponse,
MigrationType, MigrationType,
SaveUserResponse, SaveUserResponse,
SearchUsersRequest, SearchUsersRequest,
@ -249,31 +246,17 @@ export const tenantUserLookup = async (ctx: any) => {
/* /*
Encapsulate the app user onboarding flows here. Encapsulate the app user onboarding flows here.
*/ */
export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => { export const onboardUsers = async (
const request = ctx.request.body ctx: Ctx<InviteUsersRequest, InviteUsersResponse>
const isBulkCreate = "create" in request ) => {
if (await isEmailConfigured()) {
await inviteMultiple(ctx)
return
}
const emailConfigured = await isEmailConfigured() let createdPasswords: Record<string, string> = {}
const users: User[] = ctx.request.body.map(invite => {
let onboardingResponse
if (isBulkCreate) {
// @ts-ignore
const { users, groups, roles } = request.create
const assignUsers = users.map((user: User) => (user.roles = roles))
onboardingResponse = await userSdk.db.bulkCreate(assignUsers, groups)
ctx.body = onboardingResponse
} else if (emailConfigured) {
onboardingResponse = await inviteMultiple(ctx)
} else if (!emailConfigured) {
const inviteRequest = ctx.request.body
let createdPasswords: any = {}
const users: User[] = inviteRequest.map(invite => {
let password = Math.random().toString(36).substring(2, 22) let password = Math.random().toString(36).substring(2, 22)
// Temp password to be passed to the user.
createdPasswords[invite.email] = password createdPasswords[invite.email] = password
return { return {
@ -286,22 +269,12 @@ export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
tenantId: tenancy.getTenantId(), tenantId: tenancy.getTenantId(),
} }
}) })
let bulkCreateReponse = await userSdk.db.bulkCreate(users, [])
// Apply temporary credentials let resp = await userSdk.db.bulkCreate(users)
ctx.body = { for (const user of resp.successful) {
...bulkCreateReponse, user.password = createdPasswords[user.email]
successful: bulkCreateReponse?.successful.map(user => {
return {
...user,
password: createdPasswords[user.email],
}
}),
created: true,
}
} else {
ctx.throw(400, "User onboarding failed")
} }
ctx.body = { ...resp, created: true }
} }
export const invite = async (ctx: Ctx<InviteUserRequest>) => { export const invite = async (ctx: Ctx<InviteUserRequest>) => {
@ -328,18 +301,18 @@ export const invite = async (ctx: Ctx<InviteUserRequest>) => {
} }
export const inviteMultiple = async (ctx: Ctx<InviteUsersRequest>) => { export const inviteMultiple = async (ctx: Ctx<InviteUsersRequest>) => {
const request = ctx.request.body ctx.body = await userSdk.invite(ctx.request.body)
ctx.body = await userSdk.invite(request)
} }
export const checkInvite = async (ctx: any) => { export const checkInvite = async (ctx: any) => {
const { code } = ctx.params const { code } = ctx.params
let invite let invite
try { try {
invite = await checkInviteCode(code, false) invite = await redis.invite.getCode(code)
} catch (e) { } catch (e) {
console.warn("Error getting invite from code", e) console.warn("Error getting invite from code", e)
ctx.throw(400, "There was a problem with the invite") ctx.throw(400, "There was a problem with the invite")
return
} }
ctx.body = { ctx.body = {
email: invite.email, email: invite.email,
@ -347,14 +320,12 @@ export const checkInvite = async (ctx: any) => {
} }
export const getUserInvites = async (ctx: any) => { export const getUserInvites = async (ctx: any) => {
let invites
try { try {
// Restricted to the currently authenticated tenant // Restricted to the currently authenticated tenant
invites = await getInviteCodes() ctx.body = await redis.invite.getInviteCodes()
} catch (e) { } catch (e) {
ctx.throw(400, "There was a problem fetching invites") ctx.throw(400, "There was a problem fetching invites")
} }
ctx.body = invites
} }
export const updateInvite = async (ctx: any) => { export const updateInvite = async (ctx: any) => {
@ -365,12 +336,10 @@ export const updateInvite = async (ctx: any) => {
let invite let invite
try { try {
invite = await checkInviteCode(code, false) invite = await redis.invite.getCode(code)
if (!invite) {
throw new Error("The invite could not be retrieved")
}
} catch (e) { } catch (e) {
ctx.throw(400, "There was a problem with the invite") ctx.throw(400, "There was a problem with the invite")
return
} }
let updated = { let updated = {
@ -395,7 +364,7 @@ export const updateInvite = async (ctx: any) => {
} }
} }
await updateInviteCode(code, updated) await redis.invite.updateCode(code, updated)
ctx.body = { ...invite } ctx.body = { ...invite }
} }
@ -405,7 +374,8 @@ export const inviteAccept = async (
const { inviteCode, password, firstName, lastName } = ctx.request.body const { inviteCode, password, firstName, lastName } = ctx.request.body
try { try {
// info is an extension of the user object that was stored by global // info is an extension of the user object that was stored by global
const { email, info }: any = await checkInviteCode(inviteCode) const { email, info }: any = await redis.invite.getCode(inviteCode)
await redis.invite.deleteCode(inviteCode)
const user = await tenancy.doInTenant(info.tenantId, async () => { const user = await tenancy.doInTenant(info.tenantId, async () => {
let request: any = { let request: any = {
firstName, firstName,

View file

@ -1,11 +1,12 @@
import { InviteUsersResponse, User } from "@budibase/types" import { InviteUsersResponse, User } from "@budibase/types"
jest.mock("nodemailer")
import { TestConfiguration, mocks, structures } from "../../../../tests" import { TestConfiguration, mocks, structures } from "../../../../tests"
const sendMailMock = mocks.email.mock()
import { events, tenancy, accounts as _accounts } from "@budibase/backend-core" import { events, tenancy, accounts as _accounts } from "@budibase/backend-core"
import * as userSdk from "../../../../sdk/users" import * as userSdk from "../../../../sdk/users"
jest.mock("nodemailer")
const sendMailMock = mocks.email.mock()
const accounts = jest.mocked(_accounts) const accounts = jest.mocked(_accounts)
describe("/api/global/users", () => { describe("/api/global/users", () => {
@ -54,6 +55,24 @@ describe("/api/global/users", () => {
expect(events.user.invited).toBeCalledTimes(0) expect(events.user.invited).toBeCalledTimes(0)
}) })
it("should not invite the same user twice", async () => {
const email = structures.users.newEmail()
await config.api.users.sendUserInvite(sendMailMock, email)
jest.clearAllMocks()
const { code, res } = await config.api.users.sendUserInvite(
sendMailMock,
email,
400
)
expect(res.body.message).toBe(`Unavailable`)
expect(sendMailMock).toHaveBeenCalledTimes(0)
expect(code).toBeUndefined()
expect(events.user.invited).toBeCalledTimes(0)
})
it("should be able to create new user from invite", async () => { it("should be able to create new user from invite", async () => {
const email = structures.users.newEmail() const email = structures.users.newEmail()
const { code } = await config.api.users.sendUserInvite( const { code } = await config.api.users.sendUserInvite(
@ -101,6 +120,23 @@ describe("/api/global/users", () => {
expect(sendMailMock).toHaveBeenCalledTimes(0) expect(sendMailMock).toHaveBeenCalledTimes(0)
expect(events.user.invited).toBeCalledTimes(0) expect(events.user.invited).toBeCalledTimes(0)
}) })
it("should not be able to generate an invitation for user that has already been invited", async () => {
const email = structures.users.newEmail()
await config.api.users.sendUserInvite(sendMailMock, email)
jest.clearAllMocks()
const request = [{ email: email, userInfo: {} }]
const res = await config.api.users.sendMultiUserInvite(request)
const body = res.body as InviteUsersResponse
expect(body.successful.length).toBe(0)
expect(body.unsuccessful.length).toBe(1)
expect(body.unsuccessful[0].reason).toBe("Unavailable")
expect(sendMailMock).toHaveBeenCalledTimes(0)
expect(events.user.invited).toBeCalledTimes(0)
})
}) })
describe("POST /api/global/users/bulk", () => { describe("POST /api/global/users/bulk", () => {
@ -633,4 +669,25 @@ describe("/api/global/users", () => {
expect(response.body.message).toBe("Unable to delete self.") expect(response.body.message).toBe("Unable to delete self.")
}) })
}) })
describe("POST /api/global/users/onboard", () => {
it("should successfully onboard a user", async () => {
const response = await config.api.users.onboardUser([
{ email: structures.users.newEmail(), userInfo: {} },
])
expect(response.successful.length).toBe(1)
expect(response.unsuccessful.length).toBe(0)
})
it("should not onboard a user who has been invited", async () => {
const email = structures.users.newEmail()
await config.api.users.sendUserInvite(sendMailMock, email)
const response = await config.api.users.onboardUser([
{ email, userInfo: {} },
])
expect(response.successful.length).toBe(0)
expect(response.unsuccessful.length).toBe(1)
})
})
}) })

View file

@ -16,13 +16,13 @@ import {
queue, queue,
env as coreEnv, env as coreEnv,
timers, timers,
redis,
} from "@budibase/backend-core" } from "@budibase/backend-core"
db.init() db.init()
import Koa from "koa" import Koa from "koa"
import koaBody from "koa-body" import koaBody from "koa-body"
import http from "http" import http from "http"
import api from "./api" import api from "./api"
import * as redis from "./utilities/redis"
const koaSession = require("koa-session") const koaSession = require("koa-session")
import { userAgent } from "koa-useragent" import { userAgent } from "koa-useragent"
@ -72,7 +72,8 @@ server.on("close", async () => {
shuttingDown = true shuttingDown = true
console.log("Server Closed") console.log("Server Closed")
timers.cleanup() timers.cleanup()
await redis.shutdown() await redis.invite.shutdown()
await redis.passwordReset.shutdown()
await events.shutdown() await events.shutdown()
await queue.shutdown() await queue.shutdown()
if (!env.isTest()) { if (!env.isTest()) {
@ -88,7 +89,8 @@ const shutdown = () => {
export default server.listen(parseInt(env.PORT || "4002"), async () => { export default server.listen(parseInt(env.PORT || "4002"), async () => {
console.log(`Worker running on ${JSON.stringify(server.address())}`) console.log(`Worker running on ${JSON.stringify(server.address())}`)
await initPro() await initPro()
await redis.init() await redis.invite.init()
await redis.passwordReset.init()
// configure events to use the pro audit log write // configure events to use the pro audit log write
// can't integrate directly into backend-core due to cyclic issues // can't integrate directly into backend-core due to cyclic issues
await events.processors.init(proSdk.auditLogs.write) await events.processors.init(proSdk.auditLogs.write)

View file

@ -6,12 +6,12 @@ import {
sessions, sessions,
tenancy, tenancy,
utils as coreUtils, utils as coreUtils,
redis,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { PlatformLogoutOpts, User } from "@budibase/types" import { PlatformLogoutOpts, User } from "@budibase/types"
import jwt from "jsonwebtoken" import jwt from "jsonwebtoken"
import * as userSdk from "../users" import * as userSdk from "../users"
import * as emails from "../../utilities/email" import * as emails from "../../utilities/email"
import * as redis from "../../utilities/redis"
import { EmailTemplatePurpose } from "../../constants" import { EmailTemplatePurpose } from "../../constants"
// LOGIN / LOGOUT // LOGIN / LOGOUT
@ -73,7 +73,7 @@ export const reset = async (email: string) => {
* Perform the user password update if the provided reset code is valid. * Perform the user password update if the provided reset code is valid.
*/ */
export const resetUpdate = async (resetCode: string, password: string) => { export const resetUpdate = async (resetCode: string, password: string) => {
const { userId } = await redis.checkResetPasswordCode(resetCode) const { userId } = await redis.passwordReset.getCode(resetCode)
let user = await userSdk.db.getUser(userId) let user = await userSdk.db.getUser(userId)
user.password = password user.password = password

View file

@ -1,5 +1,9 @@
import { events, tenancy, users as usersCore } from "@budibase/backend-core" import { events, tenancy, users as usersCore } from "@budibase/backend-core"
import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types" import {
InviteUserRequest,
InviteUsersRequest,
InviteUsersResponse,
} from "@budibase/types"
import { sendEmail } from "../../utilities/email" import { sendEmail } from "../../utilities/email"
import { EmailTemplatePurpose } from "../../constants" import { EmailTemplatePurpose } from "../../constants"
@ -14,11 +18,13 @@ export async function invite(
const matchedEmails = await usersCore.searchExistingEmails( const matchedEmails = await usersCore.searchExistingEmails(
users.map(u => u.email) users.map(u => u.email)
) )
const newUsers = [] const newUsers: InviteUserRequest[] = []
// separate duplicates from new users // separate duplicates from new users
for (let user of users) { for (let user of users) {
if (matchedEmails.includes(user.email)) { if (matchedEmails.includes(user.email)) {
// This "Unavailable" is load bearing. The tests and frontend both check for it
// specifically
response.unsuccessful.push({ email: user.email, reason: "Unavailable" }) response.unsuccessful.push({ email: user.email, reason: "Unavailable" })
} else { } else {
newUsers.push(user) newUsers.push(user)

View file

@ -5,6 +5,7 @@ import {
User, User,
CreateAdminUserRequest, CreateAdminUserRequest,
SearchQuery, SearchQuery,
InviteUsersResponse,
} from "@budibase/types" } from "@budibase/types"
import structures from "../structures" import structures from "../structures"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
@ -176,4 +177,24 @@ export class UserAPI extends TestAPI {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
} }
onboardUser = async (
req: InviteUsersRequest
): Promise<InviteUsersResponse> => {
const resp = await this.request
.post(`/api/global/users/onboard`)
.send(req)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
if (resp.status !== 200) {
throw new Error(
`request failed with status ${resp.status} and body ${JSON.stringify(
resp.body
)}`
)
}
return resp.body as InviteUsersResponse
}
} }

View file

@ -3,7 +3,7 @@ import { EmailTemplatePurpose, TemplateType } from "../constants"
import { getTemplateByPurpose, EmailTemplates } from "../constants/templates" import { getTemplateByPurpose, EmailTemplates } from "../constants/templates"
import { getSettingsTemplateContext } from "./templates" import { getSettingsTemplateContext } from "./templates"
import { processString } from "@budibase/string-templates" import { processString } from "@budibase/string-templates"
import { getResetPasswordCode, getInviteCode } from "./redis" import { redis } from "@budibase/backend-core"
import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types" import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types"
import { configs } from "@budibase/backend-core" import { configs } from "@budibase/backend-core"
import ical from "ical-generator" import ical from "ical-generator"
@ -61,9 +61,9 @@ async function getLinkCode(
) { ) {
switch (purpose) { switch (purpose) {
case EmailTemplatePurpose.PASSWORD_RECOVERY: case EmailTemplatePurpose.PASSWORD_RECOVERY:
return getResetPasswordCode(user._id!, info) return redis.passwordReset.createCode(user._id!, info)
case EmailTemplatePurpose.INVITATION: case EmailTemplatePurpose.INVITATION:
return getInviteCode(email, info) return redis.invite.createCode(email, info)
default: default:
return null return null
} }

View file

@ -1,150 +0,0 @@
import { redis, utils, tenancy } from "@budibase/backend-core"
import env from "../environment"
function getExpirySecondsForDB(db: string) {
switch (db) {
case redis.utils.Databases.PW_RESETS:
// a hour
return 3600
case redis.utils.Databases.INVITATIONS:
// a week
return 604800
}
}
let pwResetClient: any, invitationClient: any
function getClient(db: string) {
switch (db) {
case redis.utils.Databases.PW_RESETS:
return pwResetClient
case redis.utils.Databases.INVITATIONS:
return invitationClient
}
}
async function writeACode(db: string, value: any) {
const client = await getClient(db)
const code = utils.newid()
await client.store(code, value, getExpirySecondsForDB(db))
return code
}
async function updateACode(db: string, code: string, value: any) {
const client = await getClient(db)
await client.store(code, value, getExpirySecondsForDB(db))
}
/**
* Given an invite code and invite body, allow the update an existing/valid invite in redis
* @param inviteCode The invite code for an invite in redis
* @param value The body of the updated user invitation
*/
export async function updateInviteCode(inviteCode: string, value: string) {
await updateACode(redis.utils.Databases.INVITATIONS, inviteCode, value)
}
async function getACode(db: string, code: string, deleteCode = true) {
const client = await getClient(db)
const value = await client.get(code)
if (!value) {
throw new Error("Invalid code.")
}
if (deleteCode) {
await client.delete(code)
}
return value
}
export async function init() {
pwResetClient = new redis.Client(redis.utils.Databases.PW_RESETS)
invitationClient = new redis.Client(redis.utils.Databases.INVITATIONS)
await pwResetClient.init()
await invitationClient.init()
}
/**
* make sure redis connection is closed.
*/
export async function shutdown() {
if (pwResetClient) await pwResetClient.finish()
if (invitationClient) await invitationClient.finish()
// shutdown core clients
await redis.clients.shutdown()
console.log("Redis shutdown")
}
/**
* Given a user ID this will store a code (that is returned) for an hour in redis.
* The user can then return this code for resetting their password (through their reset link).
* @param userId the ID of the user which is to be reset.
* @param info Info about the user/the reset process.
* @return returns the code that was stored to redis.
*/
export async function getResetPasswordCode(userId: string, info: any) {
return writeACode(redis.utils.Databases.PW_RESETS, { userId, info })
}
/**
* Given a reset code this will lookup to redis, check if the code is valid and delete if required.
* @param resetCode The code provided via the email link.
* @param deleteCode If the code is used/finished with this will delete it - defaults to true.
* @return returns the user ID if it is found
*/
export async function checkResetPasswordCode(
resetCode: string,
deleteCode = true
) {
try {
return getACode(redis.utils.Databases.PW_RESETS, resetCode, deleteCode)
} catch (err) {
throw "Provided information is not valid, cannot reset password - please try again."
}
}
/**
* Generates an invitation code and writes it to redis - which can later be checked for user creation.
* @param email the email address which the code is being sent to (for use later).
* @param info Information to be carried along with the invitation.
* @return returns the code that was stored to redis.
*/
export async function getInviteCode(email: string, info: any) {
return writeACode(redis.utils.Databases.INVITATIONS, { email, info })
}
/**
* Checks that the provided invite code is valid - will return the email address of user that was invited.
* @param inviteCode the invite code that was provided as part of the link.
* @param deleteCode whether or not the code should be deleted after retrieval - defaults to true.
* @return If the code is valid then an email address will be returned.
*/
export async function checkInviteCode(
inviteCode: string,
deleteCode: boolean = true
) {
try {
return getACode(redis.utils.Databases.INVITATIONS, inviteCode, deleteCode)
} catch (err) {
throw "Invitation is not valid or has expired, please request a new one."
}
}
/**
Get all currently available user invitations for the current tenant.
**/
export async function getInviteCodes() {
const client = await getClient(redis.utils.Databases.INVITATIONS)
const invites: any[] = await client.scan()
const results = invites.map(invite => {
return {
...invite.value,
code: invite.key,
}
})
if (!env.MULTI_TENANCY) {
return results
}
const tenantId = tenancy.getTenantId()
return results.filter(invite => tenantId === invite.info.tenantId)
}

View file

@ -983,10 +983,10 @@
expect "^29.0.0" expect "^29.0.0"
pretty-format "^29.0.0" pretty-format "^29.0.0"
"@types/node-fetch@2.6.2": "@types/node-fetch@2.6.4":
version "2.6.2" version "2.6.4"
resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660"
integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A== integrity sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
form-data "^3.0.0" form-data "^3.0.0"
@ -3587,18 +3587,18 @@ node-duration@^1.0.4:
resolved "https://registry.npmjs.org/node-duration/-/node-duration-1.0.4.tgz" resolved "https://registry.npmjs.org/node-duration/-/node-duration-1.0.4.tgz"
integrity sha512-eUXYNSY7DL53vqfTosggWkvyIW3bhAcqBDIlolgNYlZhianXTrCL50rlUJWD1eRqkIxMppXTfiFbp+9SjpPrgA== integrity sha512-eUXYNSY7DL53vqfTosggWkvyIW3bhAcqBDIlolgNYlZhianXTrCL50rlUJWD1eRqkIxMppXTfiFbp+9SjpPrgA==
node-fetch@2, node-fetch@2.6.7, node-fetch@^2.6.7: node-fetch@2.6.0:
version "2.6.0"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-fetch@2.6.7, node-fetch@^2.6.7:
version "2.6.7" version "2.6.7"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
dependencies: dependencies:
whatwg-url "^5.0.0" whatwg-url "^5.0.0"
node-fetch@2.6.0:
version "2.6.0"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-gyp-build-optional-packages@5.0.7: node-gyp-build-optional-packages@5.0.7:
version "5.0.7" version "5.0.7"
resolved "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz" resolved "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz"
@ -4893,10 +4893,10 @@ type-is@^1.6.16, type-is@^1.6.18:
media-typer "0.3.0" media-typer "0.3.0"
mime-types "~2.1.24" mime-types "~2.1.24"
typescript@4.7.3: typescript@5.2.2:
version "4.7.3" version "5.2.2"
resolved "https://registry.npmjs.org/typescript/-/typescript-4.7.3.tgz" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
integrity sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA== integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
uid2@0.0.x: uid2@0.0.x:
version "0.0.4" version "0.0.4"