From 365b550d5147379513553f8af6428dc3e8717e31 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Tue, 6 Feb 2024 11:59:40 +0100 Subject: [PATCH 01/14] Add magic link based on postgres adapter --- package-lock.json | 2 ++ package.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/package-lock.json b/package-lock.json index 7edd4043..7fd180be 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", @@ -29,6 +30,7 @@ "next-auth": "^4.24.5", "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", diff --git a/package.json b/package.json index c9f2eb1b..262ba2b1 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", @@ -35,6 +36,7 @@ "next-auth": "^4.24.5", "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", From c2764f629331050a35681ecf1d6701d37cc94107 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Wed, 7 Feb 2024 15:34:56 +0100 Subject: [PATCH 02/14] Basic setup for guest user admin --- src/composition.ts | 36 +++++++++++++ src/features/admin/domain/Guest.ts | 6 +++ src/features/admin/domain/IGuestInviter.ts | 3 ++ src/features/admin/domain/IGuestRepository.ts | 7 +++ src/features/admin/view/AdminPage.tsx | 51 +++++++++++++++++++ 5 files changed, 103 insertions(+) create mode 100644 src/features/admin/domain/Guest.ts create mode 100644 src/features/admin/domain/IGuestInviter.ts create mode 100644 src/features/admin/domain/IGuestRepository.ts create mode 100644 src/features/admin/view/AdminPage.tsx diff --git a/src/composition.ts b/src/composition.ts index 2386cbbf..5d2f79ee 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -38,6 +38,8 @@ import { TransferringAccessTokenReader, UserDataCleanUpLogOutHandler } from "@/features/auth/domain" +import { IGuestInviter } from "./features/admin/domain/IGuestInviter" +import { randomUUID } from "crypto" const { GITHUB_APP_ID, @@ -175,3 +177,37 @@ export const logOutHandler = new ErrorIgnoringLogOutHandler( new UserDataCleanUpLogOutHandler(session, projectUserDataRepository) ]) ) + +export const guestRepository: IGuestRepository = { + getAll: function (): Promise { + return Promise.resolve([ + { + id: randomUUID(), + email: "ulrik@shape.dk", + status: "active", + projects: ["deas", "moonboon"] + }, + { + id: randomUUID(), + email: "lars@company.com", + status: "invited", + projects: ["deas"] + } + ]) + }, + findById: function (id: string): Promise { + throw new Error("Function not implemented.") + }, + create: function (guest: Guest): Promise { + throw new Error("Function not implemented.") + }, + removeById: function (id: string): Promise { + throw new Error("Function not implemented.") + } +} + +export const guestInviter: IGuestInviter = { + inviteGuest: async (invitee: string) => { + console.log(`Inviting ${invitee}`) + } +} \ No newline at end of file diff --git a/src/features/admin/domain/Guest.ts b/src/features/admin/domain/Guest.ts new file mode 100644 index 00000000..8b81fe3e --- /dev/null +++ b/src/features/admin/domain/Guest.ts @@ -0,0 +1,6 @@ +type Guest = { + id: string; + 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..21672ee6 --- /dev/null +++ b/src/features/admin/domain/IGuestInviter.ts @@ -0,0 +1,3 @@ +export interface IGuestInviter { + inviteGuest: (invitee: string) => Promise +} diff --git a/src/features/admin/domain/IGuestRepository.ts b/src/features/admin/domain/IGuestRepository.ts new file mode 100644 index 00000000..d1b2c486 --- /dev/null +++ b/src/features/admin/domain/IGuestRepository.ts @@ -0,0 +1,7 @@ + +interface IGuestRepository { + getAll(): Promise + findById(id: string): Promise + create(guest: Guest): Promise + removeById(id: string): Promise +} diff --git a/src/features/admin/view/AdminPage.tsx b/src/features/admin/view/AdminPage.tsx new file mode 100644 index 00000000..b0b2011f --- /dev/null +++ b/src/features/admin/view/AdminPage.tsx @@ -0,0 +1,51 @@ +import { guestRepository } from "@/composition" +import { Button, ButtonGroup, Chip, FormGroup, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField } from "@mui/material" +import Table from "@mui/material/Table" + +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 From ca56c2377d3b66f1d77e491bf0f80274560ab622 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Thu, 8 Feb 2024 12:55:01 +0100 Subject: [PATCH 03/14] Init db in composition.ts --- src/composition.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/composition.ts b/src/composition.ts index 5d2f79ee..c23e8c91 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -40,6 +40,7 @@ import { } from "@/features/auth/domain" import { IGuestInviter } from "./features/admin/domain/IGuestInviter" import { randomUUID } from "crypto" +import { Pool } from "pg" const { GITHUB_APP_ID, @@ -203,6 +204,15 @@ export const guestRepository: IGuestRepository = { }, removeById: function (id: string): Promise { throw new Error("Function not implemented.") + +export const pool = new Pool({ + host: 'localhost', // TODO: Move to env + user: 'ua', // TODO: Move to env + database: 'shape-docs', // TODO: Move to env + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}) } } From d3853c6460762388fe4b3a82fb171a30c0b9a80a Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Thu, 8 Feb 2024 12:58:38 +0100 Subject: [PATCH 04/14] Add db backed guest repo --- package-lock.json | 33 ++++++-- package.json | 2 + src/app/admin/guests/page.tsx | 7 ++ src/composition.ts | 68 +++++++++------- src/features/admin/data/DbGuestRepository.ts | 77 +++++++++++++++++++ src/features/admin/domain/Guest.ts | 1 - src/features/admin/domain/IGuestInviter.ts | 2 +- src/features/admin/domain/IGuestRepository.ts | 7 +- src/features/admin/view/AdminPage.tsx | 38 +++++++-- 9 files changed, 186 insertions(+), 49 deletions(-) create mode 100644 src/app/admin/guests/page.tsx create mode 100644 src/features/admin/data/DbGuestRepository.ts diff --git a/package-lock.json b/package-lock.json index 7fd180be..27067345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "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", @@ -46,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", @@ -4145,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", @@ -4199,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", @@ -10713,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", @@ -16686,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", @@ -16704,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 262ba2b1..6f5ec056 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "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", @@ -52,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 c23e8c91..b2bf3f14 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -40,7 +40,11 @@ import { } from "@/features/auth/domain" import { IGuestInviter } from "./features/admin/domain/IGuestInviter" import { randomUUID } from "crypto" +import { createTransport } from "nodemailer" +import StreamTransport from "nodemailer/lib/stream-transport" +import SMTPTransport from "nodemailer/lib/smtp-transport" import { Pool } from "pg" +import DbGuestRepository from "./features/admin/data/DbGuestRepository" const { GITHUB_APP_ID, @@ -179,31 +183,6 @@ export const logOutHandler = new ErrorIgnoringLogOutHandler( ]) ) -export const guestRepository: IGuestRepository = { - getAll: function (): Promise { - return Promise.resolve([ - { - id: randomUUID(), - email: "ulrik@shape.dk", - status: "active", - projects: ["deas", "moonboon"] - }, - { - id: randomUUID(), - email: "lars@company.com", - status: "invited", - projects: ["deas"] - } - ]) - }, - findById: function (id: string): Promise { - throw new Error("Function not implemented.") - }, - create: function (guest: Guest): Promise { - throw new Error("Function not implemented.") - }, - removeById: function (id: string): Promise { - throw new Error("Function not implemented.") export const pool = new Pool({ host: 'localhost', // TODO: Move to env @@ -213,11 +192,42 @@ export const pool = new Pool({ idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, }) + +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 = { - inviteGuest: async (invitee: string) => { - console.log(`Inviting ${invitee}`) + 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() } -} \ No newline at end of file +} diff --git a/src/features/admin/data/DbGuestRepository.ts b/src/features/admin/data/DbGuestRepository.ts new file mode 100644 index 00000000..af228532 --- /dev/null +++ b/src/features/admin/data/DbGuestRepository.ts @@ -0,0 +1,77 @@ +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)]) + return this.findByEmail(email) + } + + /** + * Remove a guest by email + * + * @param email Email of the guest + */ + async removeByEmail(email: string): Promise { + const sql = "DELETE FROM guests WHERE email = $1 LIMIT 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 index 8b81fe3e..00fd1d14 100644 --- a/src/features/admin/domain/Guest.ts +++ b/src/features/admin/domain/Guest.ts @@ -1,5 +1,4 @@ type Guest = { - id: string; email: string; status: "active" | "invited"; projects: string[]; diff --git a/src/features/admin/domain/IGuestInviter.ts b/src/features/admin/domain/IGuestInviter.ts index 21672ee6..c358b1aa 100644 --- a/src/features/admin/domain/IGuestInviter.ts +++ b/src/features/admin/domain/IGuestInviter.ts @@ -1,3 +1,3 @@ export interface IGuestInviter { - inviteGuest: (invitee: string) => Promise + inviteGuestByEmail: (email: string) => Promise } diff --git a/src/features/admin/domain/IGuestRepository.ts b/src/features/admin/domain/IGuestRepository.ts index d1b2c486..fd95b396 100644 --- a/src/features/admin/domain/IGuestRepository.ts +++ b/src/features/admin/domain/IGuestRepository.ts @@ -1,7 +1,8 @@ interface IGuestRepository { getAll(): Promise - findById(id: string): Promise - create(guest: Guest): Promise - removeById(id: string): 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 index b0b2011f..78b3162d 100644 --- a/src/features/admin/view/AdminPage.tsx +++ b/src/features/admin/view/AdminPage.tsx @@ -1,20 +1,40 @@ -import { guestRepository } from "@/composition" +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" const AdminPage = async () => { const guests = await guestRepository.getAll() + async function sendInvite(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') + } + + async function removeGuest(formData: FormData): Promise { + 'use server' + console.log(formData) + const email = formData.get('email') as string + guestRepository.removeByEmail(email) + revalidatePath('/admin/guests') + } + return ( <>

Guest Admin

Invite Guest

- - - - - +
+ + + + + +

Guests

@@ -35,8 +55,10 @@ const AdminPage = async () => { {row.projects.join(", ")} - - +
+ + +
From cb849c7998c591dfaf32fa7275406348f449cad6 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Thu, 8 Feb 2024 13:01:12 +0100 Subject: [PATCH 05/14] Document db schemas --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/README.md b/README.md index 178baf2d..b403e3b1 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,69 @@ npm run dev Finally, open the application on https://dev.local:3000. +## Database Schemas + +### Authjs +```sql +CREATE TABLE verification_token +( + identifier TEXT NOT NULL, + expires TIMESTAMPTZ NOT NULL, + token TEXT NOT NULL, + + PRIMARY KEY (identifier, token) +); + +CREATE TABLE accounts +( + id SERIAL, + "userId" INTEGER NOT NULL, + type VARCHAR(255) NOT NULL, + provider VARCHAR(255) NOT NULL, + "providerAccountId" VARCHAR(255) NOT NULL, + refresh_token TEXT, + access_token TEXT, + expires_at BIGINT, + id_token TEXT, + scope TEXT, + session_state TEXT, + token_type TEXT, + + PRIMARY KEY (id) +); + +CREATE TABLE sessions +( + id SERIAL, + "userId" INTEGER NOT NULL, + expires TIMESTAMPTZ NOT NULL, + "sessionToken" VARCHAR(255) NOT NULL, + + PRIMARY KEY (id) +); + +CREATE TABLE users +( + id SERIAL, + name VARCHAR(255), + email VARCHAR(255), + "emailVerified" TIMESTAMPTZ, + image TEXT, + + PRIMARY KEY (id) +); + +``` + +### Guests and Project Access +```sql +CREATE TABLE guests ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + projects jsonb +); +``` + ## 🚀 Deploying the App The app is hosted on Heroku in two different environments. From ce23130bca244c6396779bd4d5c686a983367b43 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Thu, 8 Feb 2024 13:09:47 +0100 Subject: [PATCH 06/14] Fix minor error --- src/features/admin/data/DbGuestRepository.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/features/admin/data/DbGuestRepository.ts b/src/features/admin/data/DbGuestRepository.ts index af228532..1b225fe2 100644 --- a/src/features/admin/data/DbGuestRepository.ts +++ b/src/features/admin/data/DbGuestRepository.ts @@ -50,7 +50,13 @@ export default class DbGuestRepository implements IGuestRepository { 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)]) - return this.findByEmail(email) + + const insertedGuest = await this.findByEmail(email) + if (!insertedGuest) { + throw new Error("Guest not found after insert") + } + + return insertedGuest } /** From 6150da1e3619f93eec6c68043521115b732d6f84 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Thu, 8 Feb 2024 13:13:40 +0100 Subject: [PATCH 07/14] Refactor admin page --- src/features/admin/data/DbGuestRepository.ts | 2 +- src/features/admin/view/AdminPage.tsx | 45 ++++++++++++-------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/features/admin/data/DbGuestRepository.ts b/src/features/admin/data/DbGuestRepository.ts index 1b225fe2..089dbe32 100644 --- a/src/features/admin/data/DbGuestRepository.ts +++ b/src/features/admin/data/DbGuestRepository.ts @@ -65,7 +65,7 @@ export default class DbGuestRepository implements IGuestRepository { * @param email Email of the guest */ async removeByEmail(email: string): Promise { - const sql = "DELETE FROM guests WHERE email = $1 LIMIT 1" + const sql = "DELETE FROM guests WHERE email = $1" await this.pool.query(sql, [email]) } diff --git a/src/features/admin/view/AdminPage.tsx b/src/features/admin/view/AdminPage.tsx index 78b3162d..11b39194 100644 --- a/src/features/admin/view/AdminPage.tsx +++ b/src/features/admin/view/AdminPage.tsx @@ -3,25 +3,36 @@ import { Button, ButtonGroup, Chip, FormGroup, TableBody, TableCell, TableContai import Table from "@mui/material/Table" import { revalidatePath } from "next/cache" -const AdminPage = async () => { - const guests = await guestRepository.getAll() +/** + * 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 - async function sendInvite(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') - } + guestRepository.removeByEmail(email) - async function removeGuest(formData: FormData): Promise { - 'use server' - console.log(formData) - const email = formData.get('email') as string - guestRepository.removeByEmail(email) - revalidatePath('/admin/guests') - } + revalidatePath('/admin/guests') +} + +const AdminPage = async () => { + const guests = await guestRepository.getAll() return ( <> From d0e1c40d8645e03442852411a8f6e2dda91a7a5b Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Feb 2024 07:57:10 +0100 Subject: [PATCH 08/14] Remove unused and duplicate imports --- src/composition.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/composition.ts b/src/composition.ts index b2bf3f14..aa62ed93 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -39,11 +39,7 @@ import { UserDataCleanUpLogOutHandler } from "@/features/auth/domain" import { IGuestInviter } from "./features/admin/domain/IGuestInviter" -import { randomUUID } from "crypto" import { createTransport } from "nodemailer" -import StreamTransport from "nodemailer/lib/stream-transport" -import SMTPTransport from "nodemailer/lib/smtp-transport" -import { Pool } from "pg" import DbGuestRepository from "./features/admin/data/DbGuestRepository" const { @@ -183,16 +179,6 @@ export const logOutHandler = new ErrorIgnoringLogOutHandler( ]) ) - -export const pool = new Pool({ - host: 'localhost', // TODO: Move to env - user: 'ua', // TODO: Move to env - database: 'shape-docs', // TODO: Move to env - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 2000, -}) - export const guestRepository: IGuestRepository = new DbGuestRepository(pool) const transport = createTransport({ From 8f722fdbe3635f7f40063908a1bf6253df6b3ff4 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Feb 2024 15:35:24 +0100 Subject: [PATCH 09/14] Declare gitHubAppCredentials earlier --- src/composition.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/composition.ts b/src/composition.ts index aa62ed93..dece9375 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -54,6 +54,15 @@ 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") +} + const pool = new Pool({ host: POSTGRESQL_HOST, user: POSTGRESQL_USER, @@ -102,15 +111,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({ From 7918fda0febc6ab0a04ed44c5d91b59070a7cb75 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Feb 2024 16:03:28 +0100 Subject: [PATCH 10/14] Sign in using email and obtain access token via app auth --- src/composition.ts | 49 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/composition.ts b/src/composition.ts index dece9375..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, @@ -60,7 +62,8 @@ const gitHubAppCredentials = { clientSecret: GITHUB_CLIENT_SECRET, privateKey: Buffer .from(GITHUB_PRIVATE_KEY_BASE_64, "base64") - .toString("utf-8") + .toString("utf-8"), + organization: GITHUB_ORGANIZATION_NAME, } const pool = new Pool({ @@ -79,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, @@ -91,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 { From 87ccbe971337494f9915ca8edb41426563447d30 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Feb 2024 16:03:52 +0100 Subject: [PATCH 11/14] Move guest table schema to create-tables.sql --- README.md | 61 +---------------------------------------------- create-tables.sql | 6 +++++ 2 files changed, 7 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index b403e3b1..0f7751aa 100644 --- a/README.md +++ b/README.md @@ -58,66 +58,7 @@ Finally, open the application on https://dev.local:3000. ## Database Schemas -### Authjs -```sql -CREATE TABLE verification_token -( - identifier TEXT NOT NULL, - expires TIMESTAMPTZ NOT NULL, - token TEXT NOT NULL, - - PRIMARY KEY (identifier, token) -); - -CREATE TABLE accounts -( - id SERIAL, - "userId" INTEGER NOT NULL, - type VARCHAR(255) NOT NULL, - provider VARCHAR(255) NOT NULL, - "providerAccountId" VARCHAR(255) NOT NULL, - refresh_token TEXT, - access_token TEXT, - expires_at BIGINT, - id_token TEXT, - scope TEXT, - session_state TEXT, - token_type TEXT, - - PRIMARY KEY (id) -); - -CREATE TABLE sessions -( - id SERIAL, - "userId" INTEGER NOT NULL, - expires TIMESTAMPTZ NOT NULL, - "sessionToken" VARCHAR(255) NOT NULL, - - PRIMARY KEY (id) -); - -CREATE TABLE users -( - id SERIAL, - name VARCHAR(255), - email VARCHAR(255), - "emailVerified" TIMESTAMPTZ, - image TEXT, - - PRIMARY KEY (id) -); - -``` - -### Guests and Project Access -```sql -CREATE TABLE guests ( - id SERIAL PRIMARY KEY, - email VARCHAR(255) UNIQUE NOT NULL, - projects jsonb -); -``` +See `create-tables.sql` ## 🚀 Deploying the App diff --git a/create-tables.sql b/create-tables.sql index 6bed5983..cc2ae091 100644 --- a/create-tables.sql +++ b/create-tables.sql @@ -56,3 +56,9 @@ CREATE TABLE access_tokens PRIMARY KEY (provider, provider_account_id) ); + +CREATE TABLE guests ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + projects jsonb +); From 534baa7a5305d17c26b61c0e3f308ca96a808316 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Fri, 16 Feb 2024 16:04:46 +0100 Subject: [PATCH 12/14] Make refresh_token nullable We do not get refresh tokens for app auth --- create-tables.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/create-tables.sql b/create-tables.sql index cc2ae091..11733c16 100644 --- a/create-tables.sql +++ b/create-tables.sql @@ -51,7 +51,7 @@ 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) From 7c3a98d37230cd34dfd8d280ef1cfce5d724d781 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Tue, 20 Feb 2024 14:53:06 +0100 Subject: [PATCH 13/14] Add guests to drop table sql --- drop-tables.sql | 1 + 1 file changed, 1 insertion(+) 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; From 79f9fdcc4c22870e2b9c64c8a577e2ac7d06ce51 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Tue, 20 Feb 2024 15:13:12 +0100 Subject: [PATCH 14/14] Add "Manage guests" menu item --- src/features/user/view/SettingsList.tsx | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) 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 ( { }}> - + ) }