diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..e12f15cc
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,21 @@
+APP_SECRET=Ck7Rt1uiy5WGbrTFj1HBjymbBoA6zqih
+BASE_URL=http://localhost
+UI_PORT=3000
+
+DB_CLIENT=mysql2
+DB_HOST=127.0.0.1
+DB_USERNAME=parcelvoy
+DB_PASSWORD=parcelvoypassword
+DB_PORT=3306
+DB_DATABASE=parcelvoy
+
+QUEUE_DRIVER=redis
+REDIS_HOST=127.0.0.1
+REDIS_PORT=6379
+
+STORAGE_DRIVER=local
+STORAGE_BASE_URL=http://localhost/uploads
+
+AUTH_DRIVER=basic
+AUTH_BASIC_USERNAME=parcelvoy
+AUTH_BASIC_PASSWORD=password
diff --git a/.github/assets/logo-dark.png b/.github/assets/logo-dark.png
new file mode 100644
index 00000000..3e40a3dc
Binary files /dev/null and b/.github/assets/logo-dark.png differ
diff --git a/.github/assets/logo-light.png b/.github/assets/logo-light.png
new file mode 100644
index 00000000..a93bcfd1
Binary files /dev/null and b/.github/assets/logo-light.png differ
diff --git a/.gitignore b/.gitignore
index f84ece43..9e2519a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,6 +72,7 @@ typings/
# dotenv environment variables file
.env
.env.*
+!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
diff --git a/README.md b/README.md
index de81bc9d..76dbb4a5 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,18 @@
-# Parcelvoy
-Engage your customers through effortless communication.
+
+
+
+Open Source Multi-Channel Marketing
+
+Engage your customers through effortless communication.
+
+
## Features
- ๐ฌ **Cross Channel Messaging** Send data-driven emails, push notifications and text messages.
@@ -12,4 +25,25 @@ Engage your customers through effortless communication.
## ๐ Getting Started
-Documentation coming soon!
+You can run Parcelvoy locally or in the cloud easily using Docker.
+
+To get up and running quickly to try things out, copy our latest `docker-compose.yml` and `.env` file onto your machine and go!
+```
+mkdir parcelvoy && cd parcelvoy
+wget https://raw.githubusercontent.com/parcelvoy/parcelvoy/master/{.env.example,docker-compose.yml}
+mv .env.example .env
+docker compose up -d # run the Docker container
+```
+
+Login to the web app at http://localhost:3000 by entering the default credentials found in the copied `.env` file.
+
+```
+AUTH_BASIC_USERNAME=parcelvoy
+AUTH_BASIC_PASSWORD=password
+```
+
+**Note:** We would recommend changing these default credentials as well as your `APP_SECRET` before ever using Parcelvoy in production.
+
+For full documentation on the platform and more information on deployment, check out our docs.
+
+**[Explore the Docs ยป](https://docs.parcelvoy.com)**
diff --git a/apps/platform/src/auth/Auth.ts b/apps/platform/src/auth/Auth.ts
index 27aeedf1..468813d1 100644
--- a/apps/platform/src/auth/Auth.ts
+++ b/apps/platform/src/auth/Auth.ts
@@ -5,10 +5,11 @@ import SAMLProvider, { SAMLConfig } from './SAMLAuthProvider'
import { DriverConfig } from '../config/env'
import LoggerAuthProvider from './LoggerAuthProvider'
import { logger } from '../config/logger'
+import BasicAuthProvider, { BasicAuthConfig } from './BasicAuthProvider'
-export type AuthProviderName = 'saml' | 'openid' | 'logger'
+export type AuthProviderName = 'basic' | 'saml' | 'openid' | 'logger'
-export type AuthConfig = SAMLConfig | OpenIDConfig
+export type AuthConfig = BasicAuthConfig | SAMLConfig | OpenIDConfig
export interface AuthTypeConfig extends DriverConfig {
tokenLife: number
@@ -19,7 +20,9 @@ export default class Auth {
provider: AuthProvider
constructor(config?: AuthConfig) {
- if (config?.driver === 'saml') {
+ if (config?.driver === 'basic') {
+ this.provider = new BasicAuthProvider(config)
+ } else if (config?.driver === 'saml') {
this.provider = new SAMLProvider(config)
} else if (config?.driver === 'openid') {
this.provider = new OpenIDProvider(config)
diff --git a/apps/platform/src/auth/AuthController.ts b/apps/platform/src/auth/AuthController.ts
index 2419ed48..e0567a88 100644
--- a/apps/platform/src/auth/AuthController.ts
+++ b/apps/platform/src/auth/AuthController.ts
@@ -29,7 +29,7 @@ router.post('/logout', async ctx => {
if (oauth) {
await revokeAccessToken(oauth.access_token, ctx)
}
- ctx.body = {} // logout redirect env property?
+ ctx.redirect('/')
})
router.get('/logout', async ctx => {
@@ -37,7 +37,7 @@ router.get('/logout', async ctx => {
if (oauth) {
await revokeAccessToken(oauth.access_token, ctx)
}
- ctx.body = 'bye!' // TODO redirect?
+ ctx.redirect('/')
})
export default router
diff --git a/apps/platform/src/auth/AuthError.ts b/apps/platform/src/auth/AuthError.ts
index fc553c8c..d82d5da9 100644
--- a/apps/platform/src/auth/AuthError.ts
+++ b/apps/platform/src/auth/AuthError.ts
@@ -35,4 +35,9 @@ export default {
code: 1007,
statusCode: 403,
},
+ InvalidCredentials: {
+ message: 'The email and password combination provided is invalid.',
+ code: 1008,
+ statusCode: 400,
+ },
} as Record
diff --git a/apps/platform/src/auth/BasicAuthProvider.ts b/apps/platform/src/auth/BasicAuthProvider.ts
new file mode 100644
index 00000000..6905e95a
--- /dev/null
+++ b/apps/platform/src/auth/BasicAuthProvider.ts
@@ -0,0 +1,53 @@
+import { Context } from 'koa'
+import { AuthTypeConfig } from './Auth'
+import { getAdminByEmail } from './AdminRepository'
+import AuthProvider from './AuthProvider'
+import App from '../app'
+import { combineURLs, firstQueryParam } from '../utilities'
+import { Admin } from './Admin'
+import { RequestError } from '../core/errors'
+import AuthError from './AuthError'
+
+export interface BasicAuthConfig extends AuthTypeConfig {
+ driver: 'basic'
+ email: string
+ password: string
+}
+
+export default class BasicAuthProvider extends AuthProvider {
+
+ private config: BasicAuthConfig
+ constructor(config: BasicAuthConfig) {
+ super()
+ this.config = config
+ }
+
+ async start(ctx: Context) {
+
+ const redirect = firstQueryParam(ctx.request.query.r)
+
+ // Redirect to the login form
+ ctx.redirect(combineURLs([App.main.env.baseUrl, '/login/basic']) + '?r=' + redirect)
+ }
+
+ async validate(ctx: Context) {
+
+ console.log('basic auth!', ctx.request.body)
+ const { email, password } = ctx.request.body
+ if (!email || !password) throw new RequestError(AuthError.InvalidCredentials)
+
+ // Check email and password match
+ if (email !== this.config.email || password !== this.config.password) {
+ throw new RequestError(AuthError.InvalidCredentials)
+ }
+
+ // Find admin, otherwise first time, create
+ let admin = await getAdminByEmail(email)
+ if (!admin) {
+ admin = await Admin.insertAndFetch({ email, first_name: 'Admin' })
+ }
+
+ // Process the login
+ await this.login(admin.id, ctx)
+ }
+}
diff --git a/apps/platform/src/auth/LoggerAuthProvider.ts b/apps/platform/src/auth/LoggerAuthProvider.ts
index ac87f7f5..79bc888a 100644
--- a/apps/platform/src/auth/LoggerAuthProvider.ts
+++ b/apps/platform/src/auth/LoggerAuthProvider.ts
@@ -43,7 +43,7 @@ export default class LoggerAuthProvider extends AuthProvider {
}
callbackUrl(token: string): string {
- const baseUrl = combineURLs([App.main.env.baseUrl, 'auth/login'])
+ const baseUrl = combineURLs([App.main.env.baseUrl, 'api/auth/login'])
const url = new URL(baseUrl)
url.searchParams.set('token', token)
return url.href
diff --git a/apps/platform/src/config/env.ts b/apps/platform/src/config/env.ts
index 531a128c..c11d0595 100644
--- a/apps/platform/src/config/env.ts
+++ b/apps/platform/src/config/env.ts
@@ -79,6 +79,11 @@ export default (type?: EnvType): Env => {
port: parseInt(process.env.PORT!),
secret: process.env.APP_SECRET!,
auth: driver(process.env.AUTH_DRIVER, {
+ basic: () => ({
+ tokenLife: defaultTokenLife,
+ email: process.env.AUTH_BASIC_EMAIL!,
+ password: process.env.AUTH_BASIC_PASSWORD!,
+ }),
saml: () => ({
tokenLife: defaultTokenLife,
callbackUrl: process.env.AUTH_SAML_CALLBACK_URL,
diff --git a/apps/ui/src/api.ts b/apps/ui/src/api.ts
index 16907a21..77747012 100644
--- a/apps/ui/src/api.ts
+++ b/apps/ui/src/api.ts
@@ -122,6 +122,11 @@ const api = {
window.location.href = env.api.baseURL + '/auth/logout'
},
+ async basicAuth(email: string, password: string, redirect: string = '/') {
+ await client.post('/auth/login/callback', { email, password })
+ window.location.href = redirect
+ },
+
profile: {
get: async () => {
if (!cache.profile) {
diff --git a/apps/ui/src/ui/form/TextField.tsx b/apps/ui/src/ui/form/TextField.tsx
index a8079045..ca523ac8 100644
--- a/apps/ui/src/ui/form/TextField.tsx
+++ b/apps/ui/src/ui/form/TextField.tsx
@@ -6,7 +6,7 @@ import { FieldProps } from './Field'
import './TextField.css'
export interface TextFieldProps> extends FieldProps {
- type?: 'text' | 'time' | 'date' | 'datetime-local' | 'number'
+ type?: 'text' | 'time' | 'date' | 'datetime-local' | 'number' | 'password'
textarea?: boolean
size?: 'small' | 'regular'
value?: string | number | readonly string[] | undefined
diff --git a/apps/ui/src/views/auth/Auth.css b/apps/ui/src/views/auth/Auth.css
index c28f69cd..1d682605 100644
--- a/apps/ui/src/views/auth/Auth.css
+++ b/apps/ui/src/views/auth/Auth.css
@@ -14,6 +14,7 @@
}
.auth-step {
+ min-width: 400px;
max-width: 600px;
background: var(--color-background);
border: 1px solid var(--color-grey-soft);
@@ -24,3 +25,7 @@
.auth-step h1 {
margin: 0;
}
+
+.auth-step .form {
+ padding: 10px 0;
+}
diff --git a/apps/ui/src/views/auth/LoginBasic.tsx b/apps/ui/src/views/auth/LoginBasic.tsx
new file mode 100644
index 00000000..bd56a23b
--- /dev/null
+++ b/apps/ui/src/views/auth/LoginBasic.tsx
@@ -0,0 +1,49 @@
+import { useState } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import api from '../../api'
+import { ReactComponent as Logo } from '../../assets/logo.svg'
+import Alert from '../../ui/Alert'
+import FormWrapper from '../../ui/form/FormWrapper'
+import TextField from '../../ui/form/TextField'
+import './Auth.css'
+
+interface LoginBasicParams {
+ email: string
+ password: string
+}
+
+export default function Login() {
+ const [searchParams] = useSearchParams()
+ const [error, setError] = useState()
+
+ const handleLogin = async ({ email, password }: LoginBasicParams) => {
+ try {
+ await api.basicAuth(email, password, searchParams.get('r') ?? '/')
+ } catch (error: any) {
+ if (error?.response?.data) {
+ setError(error.response.data.error)
+ }
+ }
+ }
+
+ return (
+
+
+
+
+
+
Login
+
+ onSubmit={handleLogin}>
+ {form => <>
+ {error && (
+ {error}
+ )}
+
+
+ >}
+
+
+
+ )
+}
diff --git a/apps/ui/src/views/router.tsx b/apps/ui/src/views/router.tsx
index 846ec037..ddefee56 100644
--- a/apps/ui/src/views/router.tsx
+++ b/apps/ui/src/views/router.tsx
@@ -39,6 +39,7 @@ import { CampaignsIcon, JourneysIcon, ListsIcon, SettingsIcon, UsersIcon } from
import { Projects } from './project/Projects'
import { pushRecentProject } from '../utils'
import { ProjectRoleRequired } from './project/ProjectRoleRequired'
+import LoginBasic from './auth/LoginBasic'
export const useRoute = (includeProject = true) => {
const { projectId = '' } = useParams()
@@ -58,6 +59,10 @@ export const router = createBrowserRouter([
path: '/login',
element: ,
},
+ {
+ path: '/login/basic',
+ element: ,
+ },
{
path: '*',
errorElement: ,
diff --git a/docker-compose.yml b/docker-compose.yml
index e80ba12e..51afec7f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -42,7 +42,7 @@ services:
DB_HOST: mysql
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
- DB_PORT: 3306
+ DB_PORT: ${DB_PORT}
DB_DATABASE: ${DB_DATABASE}
STORAGE_DRIVER: ${STORAGE_DRIVER}
STORAGE_BASE_URL: ${STORAGE_BASE_URL}
@@ -55,6 +55,8 @@ services:
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_REGION: ${AWS_REGION}
AUTH_DRIVER: ${AUTH_DRIVER}
+ AUTH_BASIC_EMAIL: ${AUTH_BASIC_EMAIL}
+ AUTH_BASIC_PASSWORD: ${AUTH_BASIC_PASSWORD}
AUTH_SAML_CALLBACK_URL: ${AUTH_SAML_CALLBACK_URL}
AUTH_SAML_ENTRY_POINT_URL: ${AUTH_SAML_ENTRY_POINT_URL}
AUTH_SAML_ISSUER: ${AUTH_SAML_ISSUER}
@@ -75,7 +77,7 @@ services:
environment:
API_BASE_URL: ${API_BASE_URL}
ports:
- - 80:3000
+ - ${UI_PORT}:3000
volumes:
mysql_data:
driver: local
diff --git a/docs/docs/advanced/authentication.md b/docs/docs/advanced/authentication.md
index aa04d11e..e1cee45a 100644
--- a/docs/docs/advanced/authentication.md
+++ b/docs/docs/advanced/authentication.md
@@ -1,9 +1,48 @@
# Authentication
-Parcelvoy utilizes SSO for authentication supporting both SAML and OpenID. There is no local authentication method available at this time.
+Parcelvoy comes with a few different types of authentication out of the box:
+- Basic
+- SAML
+- OpenID
-## Setting up SAML
+Whereas a lot of platforms will gate SSO as a luxury feature and charge extra for it (this is known as the ***SSO Tax***) we opted to go the opposite direction and lean in completely to SSO to make sure you understand that Parcelvoy takes your security seriously. SSO is not something that only Enterprise companies should have, but should be available at every level.
-## Setting up OpenID
+## Basic
+Right out of the gate Parcelvoy is setup to use a simple ***Basic*** auth that allows for a single user that can be set inside of the environment variables. This is a limited form of auth as it does not allow for multiple users and is largely meant for evaluation purposes.
+
+To change the credentials for basic auth, modify the following environment variables and restart your Parcelvoy instance.
+```
+AUTH_BASIC_USERNAME=parcelvoy
+AUTH_BASIC_PASSWORD=password
+```
+
+
+## SAML
+***Instructions coming soon.***
+
+### Config
+| key | type | required |
+|--|--|--|
+| AUTH_DRIVER | 'saml' | true |
+| AUTH_SAML_CALLBACK_URL | string | true |
+| AUTH_SAML_ENTRY_POINT_URL | string | true |
+| AUTH_SAML_ISSUER | string | true |
+| AUTH_SAML_CERT | string | true |
+| AUTH_SAML_IS_AUTHN_SIGNED | boolean | false |
+
+## OpenID
+***Instructions coming soon.***
+
+### Config
+| key | type | required |
+|--|--|--|
+| AUTH_DRIVER | 'openid' | true |
+| AUTH_OPENID_ISSUER_URL | string | true |
+| AUTH_OPENID_CLIENT_ID | string | true |
+| AUTH_OPENID_CLIENT_SECRET | string | true |
+| AUTH_OPENID_REDIRECT_URI | string | true |
+| AUTH_OPENID_DOMAIN_WHITELIST | string | true |
### Google Workspace
You can utilize either SAML or OpenID to connect to your Google Account. We'll be highlighting how to setup SAML as it is slightly easier than OpenID to configure.
+
+***Instructions coming soon.***