diff --git a/README.md b/README.md index 178baf2d..0f7751aa 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ npm run dev Finally, open the application on https://dev.local:3000. +## Database Schemas + +See `create-tables.sql` + ## 🚀 Deploying the App The app is hosted on Heroku in two different environments. diff --git a/create-tables.sql b/create-tables.sql index 6bed5983..11733c16 100644 --- a/create-tables.sql +++ b/create-tables.sql @@ -51,8 +51,14 @@ CREATE TABLE access_tokens provider VARCHAR(255) NOT NULL, provider_account_id VARCHAR(255) NOT NULL, access_token VARCHAR(255) NOT NULL, - refresh_token VARCHAR(255) NOT NULL, + refresh_token VARCHAR(255) NULL, last_updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (provider, provider_account_id) ); + +CREATE TABLE guests ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + projects jsonb +); diff --git a/drop-tables.sql b/drop-tables.sql index 35bf3d95..82ea5042 100644 --- a/drop-tables.sql +++ b/drop-tables.sql @@ -3,3 +3,4 @@ DROP TABLE verification_token; DROP TABLE accounts; DROP TABLE sessions; DROP TABLE users; +DROP TABLE guests; diff --git a/package-lock.json b/package-lock.json index 7edd4043..27067345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "shape-docs", "version": "0.1.0", "dependencies": { + "@auth/pg-adapter": "^0.4.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@fortawesome/fontawesome-svg-core": "^6.4.2", @@ -27,8 +28,10 @@ "mobx": "^6.12.0", "next": "14.0.2", "next-auth": "^4.24.5", + "nodemailer": "^6.9.9", "npm": "^10.2.4", "octokit": "^3.1.2", + "pg": "^8.11.3", "react": "^18.2.0", "react-dom": "^18.2.0", "redis-semaphore": "^5.5.0", @@ -44,6 +47,7 @@ "@auth/pg-adapter": "^0.5.2", "@types/jest": "^29.5.8", "@types/node": "^20.9.2", + "@types/nodemailer": "^6.4.14", "@types/pg": "^8.11.0", "@types/react": "^18", "@types/react-dom": "^18", @@ -4143,9 +4147,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.8", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.8.tgz", - "integrity": "sha512-fXEFTxMV2Co8ZF5aYFJv+YeA08RTYJfhtN5c9JSv/mFEMe+xxjufCb+PHL+bJcMs/ebPUsBu+UNTEz+ydXrR6g==", + "version": "29.5.12", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", + "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -4197,6 +4201,15 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.14", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", + "integrity": "sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -10711,6 +10724,14 @@ "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "devOptional": true }, + "node_modules/nodemailer": { + "version": "6.9.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz", + "integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -16684,9 +16705,9 @@ "dev": true }, "node_modules/ts-jest": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", - "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", + "version": "29.1.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", + "integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==", "dev": true, "dependencies": { "bs-logger": "0.x", @@ -16702,7 +16723,7 @@ "ts-jest": "cli.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", diff --git a/package.json b/package.json index c9f2eb1b..6f5ec056 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test": "jest" }, "dependencies": { + "@auth/pg-adapter": "^0.4.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@fortawesome/fontawesome-svg-core": "^6.4.2", @@ -33,8 +34,10 @@ "mobx": "^6.12.0", "next": "14.0.2", "next-auth": "^4.24.5", + "nodemailer": "^6.9.9", "npm": "^10.2.4", "octokit": "^3.1.2", + "pg": "^8.11.3", "react": "^18.2.0", "react-dom": "^18.2.0", "redis-semaphore": "^5.5.0", @@ -50,6 +53,7 @@ "@auth/pg-adapter": "^0.5.2", "@types/jest": "^29.5.8", "@types/node": "^20.9.2", + "@types/nodemailer": "^6.4.14", "@types/pg": "^8.11.0", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/src/app/admin/guests/page.tsx b/src/app/admin/guests/page.tsx new file mode 100644 index 00000000..0c559a54 --- /dev/null +++ b/src/app/admin/guests/page.tsx @@ -0,0 +1,7 @@ +import AdminPage from "@/features/admin/view/AdminPage"; + +export default async function Page() { + return ( + + ) +} diff --git a/src/composition.ts b/src/composition.ts index 2386cbbf..b27f46f4 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -1,6 +1,7 @@ import { Pool } from "pg" import { NextAuthOptions } from "next-auth" import GithubProvider from "next-auth/providers/github" +import EmailProvider from "next-auth/providers/email" import PostgresAdapter from "@auth/pg-adapter" import { Adapter } from "next-auth/adapters" import RedisKeyedMutexFactory from "@/common/mutex/RedisKeyedMutexFactory" @@ -24,7 +25,8 @@ import { import { GitHubOAuthTokenRefresher, AuthjsOAuthTokenRepository, - AuthjsRepositoryAccessReader + AuthjsRepositoryAccessReader, + GitHubInstallationAccessTokenDataSource } from "@/features/auth/data" import { AccessTokenRefresher, @@ -38,6 +40,9 @@ import { TransferringAccessTokenReader, UserDataCleanUpLogOutHandler } from "@/features/auth/domain" +import { IGuestInviter } from "./features/admin/domain/IGuestInviter" +import { createTransport } from "nodemailer" +import DbGuestRepository from "./features/admin/data/DbGuestRepository" const { GITHUB_APP_ID, @@ -51,6 +56,16 @@ const { POSTGRESQL_DB } = process.env +const gitHubAppCredentials = { + appId: GITHUB_APP_ID, + clientId: GITHUB_CLIENT_ID, + clientSecret: GITHUB_CLIENT_SECRET, + privateKey: Buffer + .from(GITHUB_PRIVATE_KEY_BASE_64, "base64") + .toString("utf-8"), + organization: GITHUB_ORGANIZATION_NAME, +} + const pool = new Pool({ host: POSTGRESQL_HOST, user: POSTGRESQL_USER, @@ -67,6 +82,8 @@ const logInHandler = new OAuthAccountCredentialPersistingLogInHandler({ provider: "github" }) +const gitHubInstallationAccessTokenDataSource = new GitHubInstallationAccessTokenDataSource(gitHubAppCredentials) + export const authOptions: NextAuthOptions = { adapter: PostgresAdapter(pool) as Adapter, secret: process.env.NEXTAUTH_SECRET, @@ -79,13 +96,49 @@ export const authOptions: NextAuthOptions = { scope: "repo" } } - }) + }), + EmailProvider({ + sendVerificationRequest: ({ url }) => { + console.log("Magic link", url) // print to console for now + }, + }), ], session: { strategy: "database" }, callbacks: { - async signIn({ user, account }) { + async signIn({ user, account, email }) { + if (email && email.verificationRequest && user.email) { // in verification request flow + const guest = await guestRepository.findByEmail(user.email) + if (guest == undefined) { + return false // email not invited + } + } + else if (account?.provider == "email" && user.email) { // in sign in flow, click on magic link + // obtain access token from GitHub using app auth + const guest = await guestRepository.findByEmail(user.email) + if (guest == undefined) { + return false // email not invited + } + const accessToken = await gitHubInstallationAccessTokenDataSource.getAccessToken(guest.projects || []) + const query = ` + INSERT INTO access_tokens ( + provider, + provider_account_id, + access_token + ) + VALUES ($1, $2, $3) + ON CONFLICT (provider, provider_account_id) + DO UPDATE SET access_token = $3, last_updated_at = NOW(); + ` + await pool.query(query, [ + account.provider, + account.providerAccountId, + accessToken, + ]) + return true + } + if (account) { return await logInHandler.handleLogIn(user.id, account) } else { @@ -99,15 +152,6 @@ export const authOptions: NextAuthOptions = { } } -const gitHubAppCredentials = { - appId: GITHUB_APP_ID, - clientId: GITHUB_CLIENT_ID, - clientSecret: GITHUB_CLIENT_SECRET, - privateKey: Buffer - .from(GITHUB_PRIVATE_KEY_BASE_64, "base64") - .toString("utf-8") -} - export const session: ISession = new AuthjsSession({ authOptions }) const accessTokenReader = new TransferringAccessTokenReader({ @@ -175,3 +219,42 @@ export const logOutHandler = new ErrorIgnoringLogOutHandler( new UserDataCleanUpLogOutHandler(session, projectUserDataRepository) ]) ) + +export const guestRepository: IGuestRepository = new DbGuestRepository(pool) + +const transport = createTransport({ + host: "sandbox.smtp.mailtrap.io", + port: 2525, + auth: { + user: "0682027d57d0db", + pass: "28c3dbfbfc0af8" + } +}); + +export const guestInviter: IGuestInviter = { + inviteGuestByEmail: function (email: string): Promise { + transport.sendMail({ + to: email, + from: "no-reply@docs.shapetools.io", + subject: "You have been invited to join Shape Docs", + html: ` + + + + + +

You have been invited to join Shape Docs!

+

Shape Docs uses magic links for authentication. This means that you don't need to remember a password.

+

Click the link below to request your first magic link to log in:

+ Log in + + + `, + }) + return Promise.resolve() + } +} diff --git a/src/features/admin/data/DbGuestRepository.ts b/src/features/admin/data/DbGuestRepository.ts new file mode 100644 index 00000000..089dbe32 --- /dev/null +++ b/src/features/admin/data/DbGuestRepository.ts @@ -0,0 +1,83 @@ +import { Pool } from "pg" + +export default class DbGuestRepository implements IGuestRepository { + readonly pool: Pool + + constructor(pool: Pool) { + this.pool = pool + } + + /** + * Uses users table owned by Authjs to determine if a guest is active = there's a user with the same email + * + * @returns all guests including their status + */ + async getAll(): Promise { + const sql = ` + SELECT + g.*, + CASE + WHEN u.email IS NULL THEN 'invited' + ELSE 'active' + END as status + FROM guests g + LEFT JOIN users u ON g.email = u.email + ORDER BY g.email ASC + ` + const result = await this.pool.query(sql) + return result.rows + } + + /** + * Find a guest by email + * + * @param email Email of the guest + * @returns The guest or undefined if not found + */ + async findByEmail(email: string): Promise { + const sql = "SELECT * FROM guests WHERE email = $1" + const result = await this.pool.query(sql, [email]) + return result.rows[0] + } + + /** + * Create a guest + * + * @param email Email of the guest + * @param projects List of projects the guest should be associated with + * @returns Newly created guest + */ + async create(email: string, projects: string[]): Promise { + const sql = "INSERT INTO guests (email, projects) VALUES ($1, $2)" + await this.pool.query(sql, [email, JSON.stringify(projects)]) + + const insertedGuest = await this.findByEmail(email) + if (!insertedGuest) { + throw new Error("Guest not found after insert") + } + + return insertedGuest + } + + /** + * Remove a guest by email + * + * @param email Email of the guest + */ + async removeByEmail(email: string): Promise { + const sql = "DELETE FROM guests WHERE email = $1" + await this.pool.query(sql, [email]) + } + + /** + * Get projects for a guest + * + * @param email Email of the guest + * @returns The projects the guest is associated with. If the guest is not found, an empty array is returned. + */ + async getProjectsForEmail(email: string): Promise { + const sql = "SELECT projects FROM guests WHERE email = $1" + const result = await this.pool.query(sql, [email]) + return result.rows[0] ? result.rows[0].projects : [] + } +} diff --git a/src/features/admin/domain/Guest.ts b/src/features/admin/domain/Guest.ts new file mode 100644 index 00000000..00fd1d14 --- /dev/null +++ b/src/features/admin/domain/Guest.ts @@ -0,0 +1,5 @@ +type Guest = { + email: string; + status: "active" | "invited"; + projects: string[]; +}; diff --git a/src/features/admin/domain/IGuestInviter.ts b/src/features/admin/domain/IGuestInviter.ts new file mode 100644 index 00000000..c358b1aa --- /dev/null +++ b/src/features/admin/domain/IGuestInviter.ts @@ -0,0 +1,3 @@ +export interface IGuestInviter { + inviteGuestByEmail: (email: string) => Promise +} diff --git a/src/features/admin/domain/IGuestRepository.ts b/src/features/admin/domain/IGuestRepository.ts new file mode 100644 index 00000000..fd95b396 --- /dev/null +++ b/src/features/admin/domain/IGuestRepository.ts @@ -0,0 +1,8 @@ + +interface IGuestRepository { + getAll(): Promise + findByEmail(email: string): Promise + create(email: string, projects: string[]): Promise + removeByEmail(email: string): Promise + getProjectsForEmail(email: string): Promise +} diff --git a/src/features/admin/view/AdminPage.tsx b/src/features/admin/view/AdminPage.tsx new file mode 100644 index 00000000..11b39194 --- /dev/null +++ b/src/features/admin/view/AdminPage.tsx @@ -0,0 +1,84 @@ +import { guestInviter, guestRepository } from "@/composition" +import { Button, ButtonGroup, Chip, FormGroup, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField } from "@mui/material" +import Table from "@mui/material/Table" +import { revalidatePath } from "next/cache" + +/** + * Server action to send an invite + */ +const sendInvite = async (formData: FormData): Promise => { + 'use server' + + const email = formData.get('email') as string + const projects = formData.get('projects') as string + + guestInviter.inviteGuestByEmail(email as string) + guestRepository.create(email as string, projects.split(",").map(p => p.trim())) + + revalidatePath('/admin/guests') +} + +/** + * Server action to remove a guest + */ +const removeGuest = async (formData: FormData): Promise => { + 'use server' + + const email = formData.get('email') as string + + guestRepository.removeByEmail(email) + + revalidatePath('/admin/guests') +} + +const AdminPage = async () => { + const guests = await guestRepository.getAll() + + return ( + <> +

Guest Admin

+ +

Invite Guest

+
+ + + + + +
+ +

Guests

+ + + + + Email + Status + Projects + Actions + + + + {guests.map((row, index) => ( + + {row.email} + + {row.projects.join(", ")} + + +
+ + + +
+
+
+ ))} +
+
+
+ + ) +} + +export default AdminPage diff --git a/src/features/user/view/SettingsList.tsx b/src/features/user/view/SettingsList.tsx index a62d6883..19ec0a56 100644 --- a/src/features/user/view/SettingsList.tsx +++ b/src/features/user/view/SettingsList.tsx @@ -3,6 +3,20 @@ import ThickDivider from "@/common/ui/ThickDivider" import DocumentationVisualizationPicker from "./DocumentationVisualizationPicker" import { signOut } from "next-auth/react" +const SettingsItem = ({ onClick, href, children }: { + onClick?: () => void; + href?: string; + children?: string; +}) => + + const SettingsList = () => { return ( { }}> - + ) }