├── data └── .gitkeep ├── src ├── db │ ├── schema │ │ ├── index.ts │ │ ├── users.ts │ │ └── email-logs.ts │ ├── index.ts │ └── types.ts ├── utils │ ├── string.ts │ ├── query-client.ts │ ├── url.ts │ ├── json.ts │ ├── classname.ts │ ├── database.ts │ ├── date.ts │ ├── number.ts │ ├── guard.ts │ ├── delete-browser.ts │ ├── domain.ts │ └── browser.ts ├── pages │ ├── projects │ │ ├── [projectId] │ │ │ ├── index.astro │ │ │ ├── settings.astro │ │ │ ├── emails │ │ │ │ ├── index.astro │ │ │ │ └── [emailId] │ │ │ │ │ └── index.astro │ │ │ ├── keys │ │ │ │ ├── new.astro │ │ │ │ ├── index.astro │ │ │ │ └── [keyId] │ │ │ │ │ └── index.astro │ │ │ ├── identities │ │ │ │ ├── new.astro │ │ │ │ ├── index.astro │ │ │ │ └── [identityId] │ │ │ │ │ └── index.astro │ │ │ ├── dashboard.astro │ │ │ └── members.astro │ │ ├── new.astro │ │ └── index.astro │ ├── index.astro │ ├── 404.astro │ ├── verify-account │ │ └── [verificationCode].astro │ ├── verification-pending.astro │ ├── respond-invite │ │ └── [inviteId].astro │ ├── signup.astro │ ├── reset-password │ │ └── [resetPasswordCode].astro │ ├── api │ │ └── v1 │ │ │ ├── health.ts │ │ │ ├── webhook │ │ │ └── feedbacks.ts │ │ │ ├── auth │ │ │ ├── register.ts │ │ │ ├── login.ts │ │ │ ├── verify-account.ts │ │ │ └── send-verification-email.ts │ │ │ └── projects │ │ │ ├── [projectId] │ │ │ ├── members │ │ │ │ ├── [memberId] │ │ │ │ │ └── resend.ts │ │ │ │ └── index.ts │ │ │ ├── keys │ │ │ │ ├── [keyId] │ │ │ │ │ ├── index.ts │ │ │ │ │ └── delete.ts │ │ │ │ └── create.ts │ │ │ ├── identities │ │ │ │ └── [identityId] │ │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── update.ts │ │ │ └── emails │ │ │ │ └── [emailId] │ │ │ │ └── index.ts │ │ │ ├── create.ts │ │ │ ├── index.ts │ │ │ └── invitations │ │ │ └── [inviteId] │ │ │ ├── index.ts │ │ │ └── respond.ts │ ├── forgot-password.astro │ └── login.astro ├── lib │ ├── server-only.ts │ ├── logger.ts │ ├── hash.ts │ ├── new-id.ts │ ├── config.ts │ ├── jwt-client.ts │ ├── response.ts │ ├── http-error.ts │ ├── authenticate-api-key.ts │ ├── authenticate-user.ts │ ├── redis.ts │ ├── jwt.ts │ ├── handler.ts │ ├── error.ts │ └── rate-limit.ts ├── styles │ └── global.css ├── layouts │ ├── AuthLayout.astro │ ├── Layout.astro │ └── ProjectLayout.astro ├── components │ ├── FocusManager.tsx │ ├── OnlineManager.tsx │ ├── LoadingMessage.tsx │ ├── ProjectMembers │ │ ├── MemberRoleBadge.tsx │ │ ├── RoleDropdown.tsx │ │ ├── DeleteMemberAlertDialog.tsx │ │ ├── ProjectMemberItem.tsx │ │ └── LeaveProjectButton.tsx │ ├── Interface │ │ ├── Input.tsx │ │ ├── Textarea.tsx │ │ ├── Label.tsx │ │ ├── Popover.tsx │ │ ├── Checkbox.tsx │ │ ├── Button.tsx │ │ └── Tabs.tsx │ ├── ProjectSettings │ │ ├── DeleteProject.tsx │ │ ├── ProjectSettingsPage.tsx │ │ └── ProjectConfigurationPending.tsx │ ├── Errors │ │ └── PageError.tsx │ ├── ProjectApiKeys │ │ ├── ProjectApiKeysPage.tsx │ │ ├── ProjectApiKeyCopy.tsx │ │ ├── ProjectApiKeyItem.tsx │ │ ├── ListProjectApiKeys.tsx │ │ └── ProjectApiKeyDetails.tsx │ ├── EmptyItems.tsx │ ├── ProjectIdentities │ │ ├── CopyableTableField.tsx │ │ ├── TriggerVerifyIdentity.tsx │ │ ├── ProjectIdentityItem.tsx │ │ └── ProjectIdentityDNSTable.tsx │ ├── ScreenSize.tsx │ ├── Projects │ │ ├── TimezonePopover.tsx │ │ └── ProjectNavigation.tsx │ ├── ProjectEmails │ │ ├── EmailPreviewTabs.tsx │ │ ├── EmailIframe.tsx │ │ ├── ListEmailsTable.tsx │ │ ├── EmailEventTable.tsx │ │ ├── ProjectEmailDetails.tsx │ │ └── ListProjectEmails.tsx │ ├── AuthenticationFlow │ │ ├── ForgotPasswordForm.tsx │ │ ├── TriggerVerifyAccount.tsx │ │ └── PendingVerificationMessage.tsx │ └── Pagination.tsx ├── api │ └── project.ts ├── env.d.ts ├── hooks │ ├── use-pagination.ts │ └── use-copy-to-clipboard.ts ├── middleware.ts └── helpers │ ├── promise.ts │ └── project.ts ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── tailwind.config.mjs ├── litestream.yml ├── drizzle └── meta │ └── _journal.json ├── tsconfig.json ├── docker-compose.yml ├── astro.config.mjs ├── drizzle.config.ts ├── .dockerignore ├── .gitignore ├── scripts ├── run.sh └── db-migrate.ts ├── .env.example ├── biome.json ├── license ├── Dockerfile ├── readme.md └── package.json /data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/db/schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from './users'; 2 | export * from './email-logs'; 3 | export * from './projects'; 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export function stripQuotes(value: string): string { 2 | return (value || '').replace(/["']/g, ''); 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | export const queryClient = new QueryClient(); 4 | -------------------------------------------------------------------------------- /src/pages/projects/[projectId]/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const { projectId } = Astro.params; 3 | return Astro.redirect(`/projects/${projectId}/dashboard`); 4 | --- 5 | -------------------------------------------------------------------------------- /src/lib/server-only.ts: -------------------------------------------------------------------------------- 1 | if (typeof window !== 'undefined' && !import.meta.env.SSR) { 2 | throw new Error('This file should only be imported on the server'); 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | export function isValidUrl(url: string) { 2 | try { 3 | new URL(url); 4 | return true; 5 | } catch (err) { 6 | return false; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/json.ts: -------------------------------------------------------------------------------- 1 | export function isValidJSON(data: string): boolean { 2 | try { 3 | JSON.parse(data); 4 | return true; 5 | } catch (e) { 6 | return false; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/classname.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/database.ts: -------------------------------------------------------------------------------- 1 | import { sql } from 'drizzle-orm'; 2 | import type { AnyColumn } from 'drizzle-orm'; 3 | 4 | export const increment = (column: AnyColumn, value = 1) => { 5 | return sql`${column} + ${value}`; 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | export const logError = console.log; 2 | export const logErrorObj = console.error; 3 | export const logInfo = console.info; 4 | export const logWarning = console.warn; 5 | export const logDebug = console.debug; 6 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../layouts/Layout.astro'; 3 | return Astro.redirect('/projects'); 4 | --- 5 | 6 | 7 |
Hello
8 |
9 | -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /litestream.yml: -------------------------------------------------------------------------------- 1 | dbs: 2 | - path: /data/db.sqlite3 3 | replicas: 4 | - type: s3 5 | bucket: ${REPLICA_BUCKET_NAME} 6 | region: ${REPLICA_REGION} 7 | access-key-id: ${REPLICA_ACCESS_KEY_ID} 8 | secret-access-key: ${REPLICA_SECRET_ACCESS_KEY} -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | .no-scrollbar::-webkit-scrollbar { 2 | display: none; 3 | } 4 | 5 | .no-scrollbar { 6 | -ms-overflow-style: none; /* IE and Edge */ 7 | scrollbar-width: none; /* Firefox */ 8 | } 9 | 10 | .scrollbar-stable { 11 | scrollbar-gutter: stable; 12 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1714766029368, 9 | "tag": "0000_fluffy_hardball", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["src/*"] 7 | }, 8 | "jsx": "react-jsx", 9 | "jsxImportSource": "react" 10 | }, 11 | "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.astro"] 12 | } 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | mly-fyi: 5 | image: mly-fyi 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | container_name: mly-fyi 10 | env_file: 11 | - .env.production 12 | ports: 13 | - "4321:4321" 14 | volumes: 15 | - $PWD/data:/data -------------------------------------------------------------------------------- /src/layouts/AuthLayout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from './Layout.astro'; 3 | import type { Props as BaseLayoutProps } from './Layout.astro'; 4 | 5 | export interface Props extends BaseLayoutProps {} 6 | 7 | const props = Astro.props; 8 | --- 9 | 10 | 11 |
12 | 13 |
14 |
15 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import node from '@astrojs/node'; 2 | import tailwind from '@astrojs/tailwind'; 3 | import { defineConfig } from 'astro/config'; 4 | 5 | import react from '@astrojs/react'; 6 | 7 | // https://astro.build/config 8 | export default defineConfig({ 9 | output: 'server', 10 | adapter: node({ 11 | mode: 'standalone', 12 | }), 13 | integrations: [tailwind(), react()], 14 | }); 15 | -------------------------------------------------------------------------------- /src/pages/projects/new.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '@/layouts/Layout.astro'; 3 | import { ProjectForm } from '@/components/Projects/ProjectForm'; 4 | 5 | const { currentUser } = Astro.locals; 6 | if (!currentUser) { 7 | return Astro.redirect('/login'); 8 | } 9 | --- 10 | 11 | 12 |
13 | 14 |
15 |
16 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { defineConfig } from 'drizzle-kit'; 3 | 4 | if (!process.env.DATABASE_URL) { 5 | throw new Error('DATABASE_URL is missing'); 6 | } 7 | 8 | export default defineConfig({ 9 | verbose: true, 10 | schema: './src/db/schema/index.ts', 11 | out: './drizzle', 12 | driver: 'better-sqlite', 13 | dbCredentials: { 14 | url: process.env.DATABASE_URL, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tailwindCSS.experimental.classRegex": [ 3 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 4 | ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] 5 | ], 6 | "editor.defaultFormatter": "biomejs.biome", 7 | "editor.codeActionsOnSave": { 8 | "source.organizeImports.biome": "explicit" 9 | }, 10 | "[astro]": { 11 | "editor.defaultFormatter": "astro-build.astro-vscode" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { serverConfig } from '@/lib/config'; 2 | import Database from 'better-sqlite3'; 3 | import { drizzle } from 'drizzle-orm/better-sqlite3'; 4 | import * as schema from './schema'; 5 | 6 | if (!serverConfig.databaseUrl) { 7 | throw new Error('DATABASE_URL is missing'); 8 | } 9 | 10 | const sqlite = new Database(serverConfig.databaseUrl); 11 | 12 | export const db = drizzle(sqlite, { 13 | schema, 14 | logger: serverConfig.isDev, 15 | }); 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | .idea/ 7 | .vscode/ 8 | 9 | # dependencies 10 | node_modules/ 11 | 12 | # logs 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | pnpm-debug.log* 17 | 18 | # binaries 19 | litestream 20 | 21 | # data 22 | data/ 23 | 24 | # environment variables 25 | .env 26 | .env.production 27 | 28 | # macOS-specific files 29 | .DS_Store 30 | 31 | # Ignore .sqlite3 files 32 | *.sqlite3 33 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | export function getAllDatesBetween(from: DateTime, to: DateTime) { 4 | const fromDate = from.startOf('day'); 5 | const toDate = to.startOf('day'); 6 | 7 | const dates: string[] = []; 8 | let currentDate = fromDate; 9 | 10 | while (currentDate <= toDate) { 11 | dates.push(currentDate.toISODate() as string); 12 | currentDate = currentDate.plus({ days: 1 }); 13 | } 14 | 15 | return dates; 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/404.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../layouts/Layout.astro'; 3 | --- 4 | 5 | 6 |
7 |

404

8 |

Page not found

9 | 10 | 14 | Projects 15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /src/utils/number.ts: -------------------------------------------------------------------------------- 1 | export const formatter = Intl.NumberFormat('en-US', { 2 | useGrouping: true, 3 | }); 4 | 5 | export function formatCommaNumber(number: number): string { 6 | return formatter.format(number); 7 | } 8 | 9 | export function getPercentage(portion: number, total: number): string { 10 | if (total <= 0 || portion <= 0) { 11 | return '0'; 12 | } 13 | 14 | const percentage = (portion / total) * 100; 15 | return Math.min(percentage, 100).toFixed(2); 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env* 18 | !.env.example 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # Ignore .sqlite3 files, db.sqlite3-* 24 | *.sqlite3 25 | 26 | # Ignore /data directory 27 | /data/ 28 | !/data/.gitkeep 29 | 30 | # Ignore idea files 31 | .idea -------------------------------------------------------------------------------- /scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Set the directory of the database in a variable 5 | DB_PATH=/data/db.sqlite3 6 | 7 | # Restore the database if it does not already exist. 8 | if [ -f $DB_PATH ]; then 9 | echo "Database already exists, skipping restore" 10 | else 11 | echo "No database found, restoring from replica if exists" 12 | litestream restore -if-replica-exists $DB_PATH 13 | fi 14 | 15 | pnpm run db:migrate 16 | # Run litestream with your app as the subprocess. 17 | exec litestream replicate -exec "node ./dist/server/entry.mjs" -------------------------------------------------------------------------------- /scripts/db-migrate.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import Database from 'better-sqlite3'; 4 | import { drizzle } from 'drizzle-orm/better-sqlite3'; 5 | import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; 6 | 7 | console.log('Migrating database...'); 8 | if (!process.env.DATABASE_URL) { 9 | throw new Error('DATABASE_URL is missing'); 10 | } 11 | 12 | const sqlite = new Database(process.env.DATABASE_URL); 13 | const db = drizzle(sqlite); 14 | 15 | migrate(db, { migrationsFolder: 'drizzle' }); 16 | sqlite.close(); 17 | 18 | console.log('Database migrated.'); 19 | -------------------------------------------------------------------------------- /src/components/FocusManager.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useSyncExternalStore } from 'react'; 2 | import { focusManager } from '@tanstack/react-query'; 3 | import { queryClient } from '@/utils/query-client'; 4 | 5 | export function FocusManager() { 6 | const isFocused = useSyncExternalStore( 7 | focusManager.subscribe, 8 | () => focusManager.isFocused(), 9 | () => focusManager.isFocused(), 10 | ); 11 | 12 | useEffect(() => { 13 | if (!isFocused) { 14 | return; 15 | } 16 | 17 | queryClient.invalidateQueries(); 18 | }, [isFocused]); 19 | 20 | return null; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/OnlineManager.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useSyncExternalStore } from 'react'; 2 | import { onlineManager } from '@tanstack/react-query'; 3 | import { queryClient } from '@/utils/query-client'; 4 | 5 | export function OnlineManager() { 6 | const isOnline = useSyncExternalStore( 7 | onlineManager.subscribe, 8 | () => onlineManager.isOnline(), 9 | () => onlineManager.isOnline(), 10 | ); 11 | 12 | useEffect(() => { 13 | if (!isOnline) { 14 | return; 15 | } 16 | 17 | queryClient.invalidateQueries(); 18 | }, [isOnline]); 19 | 20 | return null; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/guard.ts: -------------------------------------------------------------------------------- 1 | import { readTokenCookie } from '@/lib/jwt'; 2 | import type { APIContext, AstroGlobal } from 'astro'; 3 | 4 | function isGuest(context: APIContext | AstroGlobal): boolean { 5 | return !readTokenCookie(context); 6 | } 7 | 8 | export function requireAuthentication(Astro: AstroGlobal): Response | null { 9 | if (isGuest(Astro)) { 10 | return Astro.redirect('/login'); 11 | } 12 | 13 | return null; 14 | } 15 | 16 | export function requireGuest(Astro: APIContext | AstroGlobal): Response | null { 17 | if (!isGuest(Astro)) { 18 | return Astro.redirect('/'); 19 | } 20 | 21 | return null; 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/verify-account/[verificationCode].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import AuthLayout from '@/layouts/AuthLayout.astro'; 3 | import { TriggerVerifyAccount } from '@/components/AuthenticationFlow/TriggerVerifyAccount'; 4 | 5 | const { verificationCode } = Astro.params; 6 | if (!verificationCode) { 7 | return Astro.redirect('/login'); 8 | } 9 | 10 | const { currentUserId } = Astro.locals; 11 | if (currentUserId) { 12 | return Astro.redirect('/'); 13 | } 14 | --- 15 | 16 | 17 |
18 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /src/api/project.ts: -------------------------------------------------------------------------------- 1 | import type { ListProjectsResponse } from '@/pages/api/v1/projects'; 2 | import type { GetProjectResponse } from '@/pages/api/v1/projects/[projectId]/index.ts'; 3 | import type { APIContext, AstroGlobal } from 'astro'; 4 | import { api } from './api.ts'; 5 | 6 | export function projectApi(context: APIContext | AstroGlobal) { 7 | return { 8 | listProjects: () => { 9 | return api(context).get(`/api/v1/projects`); 10 | }, 11 | getProject: (projectId: string) => { 12 | return api(context).get( 13 | `/api/v1/projects/${projectId}`, 14 | ); 15 | }, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/LoadingMessage.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from 'lucide-react'; 2 | import React from 'react'; 3 | 4 | type LoadingMessageProps = { 5 | message?: string; 6 | }; 7 | 8 | export function LoadingMessage(props: LoadingMessageProps) { 9 | const { message = 'Please wait..' } = props; 10 | 11 | return ( 12 |
13 |
14 | 15 |

{message}

16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/hash.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto'; 2 | 3 | export async function hashPassword(password: string): Promise { 4 | const salt = crypto.randomBytes(16).toString('hex'); 5 | const hash = crypto 6 | .pbkdf2Sync(password, salt, 1000, 64, 'sha512') 7 | .toString('hex'); 8 | 9 | return `${salt}$${hash}`; 10 | } 11 | 12 | export async function verifyPassword( 13 | password: string, 14 | hashedPassword: string, 15 | ): Promise { 16 | const [salt, hash] = hashedPassword.split('$'); 17 | const newHash = crypto 18 | .pbkdf2Sync(password, salt, 1000, 64, 'sha512') 19 | .toString('hex'); 20 | 21 | return newHash === hash; 22 | } 23 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_APP_URL=http://localhost:3000 2 | 3 | # Database Configuration 4 | DATABASE_URL=./data/db.sqlite3 5 | 6 | # JSON Web Token (JWT) Secret and Expiration Time 7 | JWT_SECRET= 8 | JWT_EXPIRES_IN=31d 9 | 10 | # Redis Configuration 11 | REDIS_URL=redis://localhost:6379 12 | 13 | # Amazon Simple Email Service (SES) Configuration 14 | AWS_SES_ACCESS_KEY_ID= 15 | AWS_SES_SECRET_ACCESS_KEY= 16 | AWS_SES_REGION=ap-south-1 17 | AWS_SES_ENDPOINT_OVERRIDE_URL= 18 | AWS_SES_FROM_EMAIL=noreply@mly.fyi 19 | 20 | # Amazon Simple Storage Service (S3) Configuration 21 | REPLICA_ACCESS_KEY_ID= 22 | REPLICA_SECRET_ACCESS_KEY= 23 | REPLICA_REGION=us-east-2 24 | REPLICA_BUCKET_NAME=db-sqlite 25 | -------------------------------------------------------------------------------- /src/components/ProjectMembers/MemberRoleBadge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/classname.ts'; 2 | import type { AllowedProjectMemberRole } from './RoleDropdown.tsx'; 3 | 4 | export function MemberRoleBadge({ role }: { role: AllowedProjectMemberRole }) { 5 | return ( 6 | 16 | {role} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/verification-pending.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { PendingVerificationMessage } from '../components/AuthenticationFlow/PendingVerificationMessage'; 3 | import AuthLayout from '../layouts/AuthLayout.astro'; 4 | 5 | const rawEmail = Astro.url.searchParams.get('email'); 6 | if (!rawEmail) { 7 | return Astro.redirect('/'); 8 | } 9 | 10 | const email = decodeURIComponent(rawEmail); 11 | 12 | const { currentUserId } = Astro.locals; 13 | if (currentUserId) { 14 | return Astro.redirect('/'); 15 | } 16 | --- 17 | 18 | 19 |
20 | 21 |
22 |
23 | -------------------------------------------------------------------------------- /src/pages/respond-invite/[inviteId].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import AuthLayout from '@/layouts/AuthLayout.astro'; 3 | import { RespondInviteForm } from '@/components/AuthenticationFlow/RespondInviteForm'; 4 | 5 | const { inviteId } = Astro.params; 6 | if (!inviteId) { 7 | return Astro.redirect('/login'); 8 | } 9 | 10 | const { currentUserId } = Astro.locals; 11 | if (!currentUserId) { 12 | return Astro.redirect('/login'); 13 | } 14 | --- 15 | 16 | 17 |
20 | 21 |
22 |
23 | -------------------------------------------------------------------------------- /src/components/Interface/Input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/classname'; 2 | import React from 'react'; 3 | 4 | export interface InputProps 5 | extends React.InputHTMLAttributes {} 6 | 7 | const Input = React.forwardRef( 8 | ({ className, type, ...props }, ref) => { 9 | return ( 10 | 19 | ); 20 | }, 21 | ); 22 | Input.displayName = 'Input'; 23 | 24 | export { Input }; 25 | -------------------------------------------------------------------------------- /src/db/types.ts: -------------------------------------------------------------------------------- 1 | import type { InferSelectModel } from 'drizzle-orm'; 2 | import type { 3 | emailLogEvents, 4 | emailLogs, 5 | projectApiKeys, 6 | projectIdentities, 7 | projectMembers, 8 | projects, 9 | users, 10 | } from './schema'; 11 | 12 | export type * from './schema'; 13 | export type User = InferSelectModel; 14 | export type EmailLog = InferSelectModel; 15 | export type EmailLogEvent = InferSelectModel; 16 | export type Project = InferSelectModel; 17 | export type ProjectMember = InferSelectModel; 18 | export type ProjectIdentity = InferSelectModel; 19 | export type ProjectApiKey = InferSelectModel; 20 | -------------------------------------------------------------------------------- /src/components/ProjectSettings/DeleteProject.tsx: -------------------------------------------------------------------------------- 1 | import { DeleteProjectDialog } from './DeleteProjectDialog'; 2 | 3 | type DeleteProjectProps = { 4 | projectId: string; 5 | projectName: string; 6 | }; 7 | 8 | export function DeleteProject(props: DeleteProjectProps) { 9 | const { projectId, projectName } = props; 10 | 11 | return ( 12 |
13 |

Delete Project

14 |

15 | Permanently delete this project. This action cannot be undone and all 16 | data associated with this project will be lost. 17 |

18 | 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | interface ImportMetaEnv { 5 | PUBLIC_APP_URL: string; 6 | 7 | JWT_SECRET: string; 8 | JWT_EXPIRES_IN: string; 9 | AWS_SES_REGION: string; 10 | 11 | AWS_SES_ACCESS_KEY: string; 12 | AWS_SES_SECRET_ACCESS_KEY: string; 13 | AWS_SES_FROM_EMAIL: string; 14 | } 15 | 16 | interface ImportMeta { 17 | readonly env: ImportMetaEnv; 18 | } 19 | 20 | declare namespace App { 21 | interface Locals { 22 | currentUser: 23 | | Pick 24 | | undefined; 25 | currentUserId: string | undefined; 26 | rateLimit: import('./lib/rate-limit.ts').RateLimitResponse | undefined; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Interface/Textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cn } from '../../utils/classname'; 3 | 4 | export interface TextareaProps 5 | extends React.TextareaHTMLAttributes {} 6 | 7 | const Textarea = React.forwardRef( 8 | ({ className, ...props }, ref) => { 9 | return ( 10 |