;
24 | export type ValidateRoute = (params: P) => Promise
;
25 | export type MiddlewareRoute
= (params: P) => Promise
;
26 |
27 | /**
28 | * Wraps the API handlers for error handling and other common tasks
29 | *
30 | * @param handler Handler to execute against the request
31 | *
32 | * @returns APIRoute
33 | */
34 | export function handler(
35 | handle: HandleRoute,
36 | validate: ValidateRoute | undefined = undefined,
37 | middlewares: MiddlewareRoute[] = [],
38 | ): APIRoute {
39 | return async (context: APIContext): Promise => {
40 | try {
41 | const { request, url } = context;
42 | const queryParams = Object.fromEntries(url.searchParams);
43 | const body = await request.json().catch(() => ({}));
44 |
45 | let routeParams: RouteParams = {
46 | query: queryParams,
47 | body,
48 | headers: request.headers,
49 | context,
50 | };
51 |
52 | for (const middleware of middlewares) {
53 | routeParams = await middleware(routeParams);
54 | }
55 |
56 | if (validate) {
57 | routeParams = await validate(routeParams);
58 | }
59 |
60 | const handleResponse = await handle(routeParams);
61 | return handleResponse;
62 | } catch (e) {
63 | if (e instanceof Joi.ValidationError) {
64 | return jsonWithRateLimit(renderValidationError(e), context);
65 | }
66 |
67 | if (HttpError.isHttpError(e)) {
68 | return jsonWithRateLimit(renderHttpError(e), context);
69 | }
70 |
71 | if (RateLimitError.isRateLimitError(e)) {
72 | return jsonWithRateLimit(renderRateLimitError(e), context);
73 | }
74 |
75 | return renderInternalError(e as Error);
76 | }
77 | };
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/ProjectEmails/ListEmailsTable.tsx:
--------------------------------------------------------------------------------
1 | import type { ListProjectEmailsResponse } from '@/pages/api/v1/projects/[projectId]/emails';
2 | import { DateTime } from 'luxon';
3 |
4 | type ListProjectEmailsTableProps = {
5 | emails: ListProjectEmailsResponse['data'];
6 | };
7 |
8 | export function ListProjectEmailsTable(props: ListProjectEmailsTableProps) {
9 | const { emails = [] } = props;
10 |
11 | return (
12 |
13 |
14 |
15 |
16 | To
17 |
18 |
19 | Subject
20 |
21 |
22 | Status
23 |
24 |
25 | Send At
26 |
27 |
28 |
29 |
30 | {emails?.map((email) => {
31 | const status = email.status.replace('-', ' ');
32 | const sendAt =
33 | email?.sendAt &&
34 | DateTime.fromJSDate(new Date(email?.sendAt)).toRelative();
35 |
36 | const detailsUrl = `/projects/${email.projectId}/emails/${email.id}`;
37 |
38 | return (
39 |
40 |
41 |
42 | {email.to} ↗
43 |
44 |
45 |
46 | {email.subject}
47 |
48 |
49 | {status}
50 |
51 |
52 | {sendAt || ''}
53 |
54 |
55 | );
56 | })}
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/AuthenticationFlow/ForgotPasswordForm.tsx:
--------------------------------------------------------------------------------
1 | import { httpPost } from '@/lib/http';
2 | import { queryClient } from '@/utils/query-client';
3 | import { useMutation } from '@tanstack/react-query';
4 | import { Loader2 } from 'lucide-react';
5 | import type { FormEvent } from 'react';
6 | import { useState } from 'react';
7 | import { toast } from 'sonner';
8 | import { Button } from '../Interface/Button';
9 | import { Input } from '../Interface/Input';
10 |
11 | type ForgotPasswordFormProps = {};
12 |
13 | export function ForgotPasswordForm(props: ForgotPasswordFormProps) {
14 | const [email, setEmail] = useState('');
15 |
16 | const forgotPassword = useMutation(
17 | {
18 | mutationKey: ['forgot-password', email],
19 | mutationFn: () => {
20 | return httpPost(`/api/v1/auth/forgot-password`, {
21 | email,
22 | });
23 | },
24 | onSuccess: () => {
25 | setEmail('');
26 | },
27 | onError: (error) => {
28 | // @todo use proper types
29 | if ((error as any).type === 'user_not_verified') {
30 | window.location.href = `/verification-pending?email=${encodeURIComponent(
31 | email,
32 | )}`;
33 | return;
34 | }
35 | },
36 | },
37 | queryClient,
38 | );
39 |
40 | const handleFormSubmit = (e: FormEvent) => {
41 | e.preventDefault();
42 | toast.promise(forgotPassword.mutateAsync(), {
43 | loading: 'Please wait...',
44 | success: 'Check your email for the reset link.',
45 | error: (error) => {
46 | return error?.message || 'Something went wrong.';
47 | },
48 | });
49 | };
50 |
51 | const isLoading = forgotPassword.status === 'pending';
52 |
53 | return (
54 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/AuthenticationFlow/TriggerVerifyAccount.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { httpPost } from '@/lib/http.ts';
3 | import { Ban, Loader2 } from 'lucide-react';
4 | import { useMutation } from '@tanstack/react-query';
5 | import { queryClient } from '@/utils/query-client.ts';
6 | import { redirectAuthSuccess, setAuthToken } from '@/lib/jwt-client.ts';
7 |
8 | type TriggerVerifyAccountProps = {
9 | code: string;
10 | };
11 |
12 | export function TriggerVerifyAccount(props: TriggerVerifyAccountProps) {
13 | const { code } = props;
14 |
15 | const [isLoading, setIsLoading] = useState(true);
16 | const [error, setError] = useState('');
17 |
18 | const triggerVerify = useMutation(
19 | {
20 | mutationKey: ['v1-verify-account'],
21 | mutationFn: async () => {
22 | return httpPost<{ token: string }>(`/api/v1/auth/verify-account`, {
23 | code,
24 | });
25 | },
26 | onSuccess: (data) => {
27 | const token = data?.token;
28 | if (!token) {
29 | setError('Something went wrong. Please try again.');
30 | setIsLoading(false);
31 | return;
32 | }
33 |
34 | setAuthToken(token);
35 | redirectAuthSuccess();
36 | },
37 | onError: (error) => {
38 | setError(error?.message || 'Something went wrong. Please try again.');
39 | setIsLoading(false);
40 | },
41 | },
42 | queryClient,
43 | );
44 |
45 | useEffect(() => {
46 | triggerVerify.mutate();
47 | }, []);
48 |
49 | const loadingMessage = isLoading && (
50 |
51 |
52 |
53 | Please wait while we verify you..
54 |
55 |
56 | );
57 |
58 | const errorMessage = error && !isLoading && (
59 |
60 |
61 |
{error}
62 |
63 | );
64 |
65 | return (
66 |
67 |
68 | {loadingMessage}
69 | {errorMessage}
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mly.fyi",
3 | "type": "module",
4 | "version": "0.0.1",
5 | "scripts": {
6 | "dev": "astro dev --port 3000",
7 | "start": "astro dev",
8 | "build": "astro check && astro build && npm run copy:templates",
9 | "preview": "astro preview",
10 | "astro": "astro",
11 | "db:generate": "drizzle-kit generate:sqlite --config=drizzle.config.ts",
12 | "db:migrate": "tsx ./scripts/db-migrate.ts",
13 | "db:studio": "drizzle-kit studio --config=drizzle.config.ts",
14 | "format:write": "biome format --write ./src",
15 | "copy:templates": "mkdir -p ./dist/server/src && cp -r ./src/templates ./dist/server/src/templates"
16 | },
17 | "dependencies": {
18 | "@astrojs/check": "^0.5.10",
19 | "@astrojs/node": "^8.2.5",
20 | "@astrojs/react": "^3.3.1",
21 | "@astrojs/tailwind": "^5.1.0",
22 | "@aws-sdk/client-cloudfront": "^3.569.0",
23 | "@aws-sdk/client-ses": "^3.565.0",
24 | "@aws-sdk/client-sns": "^3.565.0",
25 | "@radix-ui/react-alert-dialog": "^1.0.5",
26 | "@radix-ui/react-checkbox": "^1.0.4",
27 | "@radix-ui/react-dialog": "^1.0.5",
28 | "@radix-ui/react-dropdown-menu": "^2.0.6",
29 | "@radix-ui/react-icons": "^1.3.0",
30 | "@radix-ui/react-label": "^2.0.2",
31 | "@radix-ui/react-popover": "^1.0.7",
32 | "@radix-ui/react-slot": "^1.0.2",
33 | "@radix-ui/react-tabs": "^1.0.4",
34 | "@tanstack/react-query": "^5.32.1",
35 | "@tanstack/react-query-devtools": "^5.32.1",
36 | "@types/react": "^18.3.1",
37 | "@types/react-dom": "^18.3.0",
38 | "astro": "^4.7.0",
39 | "better-sqlite3": "^9.6.0",
40 | "class-variance-authority": "^0.7.0",
41 | "clsx": "^2.1.1",
42 | "detect-browser": "^5.3.0",
43 | "dotenv": "^16.4.5",
44 | "drizzle-orm": "^0.30.10",
45 | "joi": "^17.13.0",
46 | "jose": "^5.2.4",
47 | "js-cookie": "^3.0.5",
48 | "lucide-react": "^0.376.0",
49 | "luxon": "^3.4.4",
50 | "nanoid": "^5.0.7",
51 | "nodemailer": "^6.9.13",
52 | "react": "^18.3.1",
53 | "react-dom": "^18.3.1",
54 | "redis": "^4.6.13",
55 | "sonner": "^1.4.41",
56 | "tailwind-merge": "^2.3.0",
57 | "tailwindcss": "^3.4.3",
58 | "typescript": "^5.4.5",
59 | "uuid": "^9.0.1"
60 | },
61 | "devDependencies": {
62 | "@biomejs/biome": "1.7.2",
63 | "@types/better-sqlite3": "^7.6.10",
64 | "@types/js-cookie": "^3.0.6",
65 | "@types/luxon": "^3.4.2",
66 | "@types/node": "^20.12.8",
67 | "@types/nodemailer": "^6.4.14",
68 | "@types/uuid": "^9.0.8",
69 | "drizzle-kit": "^0.20.17",
70 | "tsx": "^4.8.2"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/ProjectMembers/DeleteMemberAlertDialog.tsx:
--------------------------------------------------------------------------------
1 | import { httpDelete } from '@/lib/http.ts';
2 | import { queryClient } from '@/utils/query-client.ts';
3 | import { useMutation } from '@tanstack/react-query';
4 | import { toast } from 'sonner';
5 | import type { GetProjectMembersResponse } from '@/pages/api/v1/projects/[projectId]/members/index.ts';
6 | import {
7 | AlertDialog,
8 | AlertDialogAction,
9 | AlertDialogCancel,
10 | AlertDialogContent,
11 | AlertDialogDescription,
12 | AlertDialogFooter,
13 | AlertDialogHeader,
14 | AlertDialogTitle,
15 | } from '../Interface/AlertDialog';
16 |
17 | type DeleteMemberAlertDialogProps = {
18 | isDeleting: boolean;
19 | setIsDeleting: (value: boolean) => void;
20 | member: GetProjectMembersResponse[number];
21 | };
22 |
23 | export function DeleteMemberAlertDialog(props: DeleteMemberAlertDialogProps) {
24 | const { isDeleting, setIsDeleting, member } = props;
25 |
26 | const deleteMember = useMutation(
27 | {
28 | mutationKey: ['delete-member', member.id],
29 | mutationFn: () => {
30 | return httpDelete(
31 | `/api/v1/projects/${member.projectId}/members/${member.id}/delete`,
32 | );
33 | },
34 | onSuccess: () => {
35 | queryClient.invalidateQueries({
36 | queryKey: ['project-members', member.projectId],
37 | });
38 | },
39 | },
40 | queryClient,
41 | );
42 |
43 | return (
44 |
45 | {
47 | e.preventDefault();
48 | }}
49 | className='max-w-sm'
50 | >
51 |
52 | Delete Member
53 |
54 | Are you sure you want to delete{' '}
55 | {member.invitedEmail} from the project?
56 |
57 |
58 |
59 |
60 | Cancel
61 | {
63 | toast.promise(deleteMember.mutateAsync(), {
64 | loading: 'Deleting member..',
65 | success: 'Member deleted',
66 | error: (e) => {
67 | return e?.message || 'Failed to delete member';
68 | },
69 | });
70 | }}
71 | >
72 | Delete
73 |
74 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/pages/api/v1/auth/register.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { users } from '@/db/schema';
3 | import { sendVerificationEmail } from '@/lib/auth-email';
4 | import {
5 | type HandleRoute,
6 | type RouteParams,
7 | type ValidateRoute,
8 | handler,
9 | } from '@/lib/handler';
10 | import { hashPassword } from '@/lib/hash';
11 | import { HttpError } from '@/lib/http-error';
12 | import { newId } from '@/lib/new-id';
13 | import { rateLimitMiddleware } from '@/lib/rate-limit';
14 | import { json, jsonWithRateLimit } from '@/lib/response';
15 | import type { APIRoute } from 'astro';
16 | import { eq } from 'drizzle-orm';
17 | import Joi from 'joi';
18 | import { v4 as uuidV4 } from 'uuid';
19 |
20 | export interface RegisterResponse {
21 | status: 'ok';
22 | }
23 |
24 | export type RegisterBody = {
25 | name: string;
26 | email: string;
27 | password: string;
28 | };
29 | export interface RegisterRequest extends RouteParams {}
30 |
31 | async function validate(params: RegisterRequest) {
32 | const schema = Joi.object({
33 | name: Joi.string().trim().min(3).max(255).required(),
34 | email: Joi.string().email().trim().lowercase().required(),
35 | password: Joi.string().trim().min(8).max(25).required(),
36 | });
37 |
38 | const { error, value } = schema.validate(params.body, {
39 | abortEarly: false,
40 | stripUnknown: true,
41 | });
42 |
43 | if (error) {
44 | throw error;
45 | }
46 |
47 | const alreadyExists = await db.query.users.findFirst({
48 | where: eq(users.email, value.email),
49 | columns: {
50 | id: true,
51 | },
52 | });
53 |
54 | if (alreadyExists) {
55 | throw new HttpError('conflict', 'User already exists');
56 | }
57 |
58 | return {
59 | ...params,
60 | body: value,
61 | };
62 | }
63 |
64 | async function handle({ body, context }: RegisterRequest) {
65 | const { name, email, password } = body;
66 |
67 | const verificationCode = uuidV4();
68 | const userId = newId('user');
69 |
70 | const hashedPassword = await hashPassword(password);
71 | await db.insert(users).values({
72 | id: userId,
73 | name,
74 | email,
75 | password: hashedPassword,
76 | verificationCode,
77 | authProvider: 'email',
78 | verificationCodeAt: new Date(),
79 | createdAt: new Date(),
80 | updatedAt: new Date(),
81 | });
82 |
83 | await sendVerificationEmail(email, verificationCode);
84 |
85 | return jsonWithRateLimit(json({ status: 'ok' }), context);
86 | }
87 |
88 | export const POST: APIRoute = handler(
89 | handle satisfies HandleRoute,
90 | validate satisfies ValidateRoute,
91 | [rateLimitMiddleware()],
92 | );
93 |
--------------------------------------------------------------------------------
/src/components/ProjectApiKeys/ListProjectApiKeys.tsx:
--------------------------------------------------------------------------------
1 | import { httpGet } from '@/lib/http';
2 | import type { ListProjectApiKeysResponse } from '@/pages/api/v1/projects/[projectId]/keys/index';
3 | import { queryClient } from '@/utils/query-client';
4 | import { useQuery } from '@tanstack/react-query';
5 | import { Key } from 'lucide-react';
6 | import { EmptyItems } from '../EmptyItems';
7 | import { PageError } from '../Errors/PageError';
8 | import { LoadingMessage } from '../LoadingMessage';
9 | import { ProjectApiKeyItem } from './ProjectApiKeyItem';
10 |
11 | type ListProjectApiKeysProps = {
12 | projectId: string;
13 | };
14 |
15 | export function ListProjectApiKeys(props: ListProjectApiKeysProps) {
16 | const { projectId } = props;
17 |
18 | const { data, error } = useQuery(
19 | {
20 | queryKey: ['project-api-keys', projectId],
21 | queryFn: () => {
22 | return httpGet(
23 | `/api/v1/projects/${projectId}/keys`,
24 | );
25 | },
26 | },
27 | queryClient,
28 | );
29 |
30 | if (error && !data) {
31 | return (
32 |
36 | );
37 | }
38 |
39 | if (!data) {
40 | return ;
41 | }
42 |
43 | const apiKeys = data.data;
44 |
45 | return (
46 | <>
47 | {apiKeys.length === 0 && (
48 |
55 | )}
56 |
57 | {apiKeys.length > 0 && (
58 | <>
59 |
68 |
69 |
70 |
71 | {apiKeys.map((apiKey) => {
72 | return (
73 |
74 |
75 |
76 | );
77 | })}
78 |
79 |
80 | >
81 | )}
82 | >
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/lib/error.ts:
--------------------------------------------------------------------------------
1 | import type { APIContext } from 'astro';
2 | import Joi from 'joi';
3 | import { stripQuotes } from '../utils/string';
4 | import type { HttpError, RateLimitError } from './http-error';
5 | import { logError } from './logger';
6 | import { json } from './response';
7 |
8 | export const ERROR_CODE_BY_KEY = {
9 | bad_request: 400,
10 | validation_error: 400,
11 | internal_error: 500,
12 | not_found: 404,
13 | unauthorized: 401,
14 | forbidden: 403,
15 | conflict: 409,
16 | not_implemented: 501,
17 | user_not_verified: 400,
18 | rate_limited: 429,
19 | } as const;
20 |
21 | export type ErrorCodeKey = keyof typeof ERROR_CODE_BY_KEY;
22 |
23 | export interface ErrorBody {
24 | type: ErrorCodeKey;
25 | status: number;
26 | message: string;
27 | errors?: {
28 | message: string;
29 | path: string;
30 | }[];
31 | }
32 |
33 | /**
34 | * Renders the error response
35 | * @param error Body of the error response
36 | * @param status Status code
37 | * @returns Response
38 | */
39 | export function renderErrorResponse(error: ErrorBody, status: number) {
40 | return json(error, { status });
41 | }
42 |
43 | export function renderHttpError(error: HttpError): Response {
44 | return renderErrorResponse(
45 | {
46 | type: error.type || 'internal_error',
47 | status: error.status,
48 | message: error.message,
49 | errors:
50 | error?.errors?.map((err) => ({
51 | message: err.message,
52 | path: err.location,
53 | })) || [],
54 | },
55 | error.status,
56 | );
57 | }
58 |
59 | export function renderRateLimitError(e: RateLimitError): Response {
60 | return json(
61 | {
62 | type: e.type,
63 | status: e.status,
64 | message: e.message,
65 | errors: e.errors,
66 | },
67 | {
68 | status: e.status,
69 | },
70 | );
71 | }
72 |
73 | export function renderValidationError(error: Joi.ValidationError): Response {
74 | const errorsList = error.details || [];
75 |
76 | return renderErrorResponse(
77 | {
78 | type: 'validation_error',
79 | status: 400,
80 | message: stripQuotes(error.message),
81 | errors: errorsList.map((err) => ({
82 | message: stripQuotes(err.message),
83 | path: err.path.join('.'),
84 | })),
85 | },
86 | 400,
87 | );
88 | }
89 |
90 | export function renderInternalError(err: Error): Response {
91 | if (err.stack) {
92 | logError(err.stack);
93 | }
94 |
95 | return renderErrorResponse(
96 | {
97 | type: 'internal_error',
98 | status: 500,
99 | message: err.message,
100 | errors: [],
101 | },
102 | 500,
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/src/components/ProjectMembers/ProjectMemberItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { GetProjectMembersResponse } from '@/pages/api/v1/projects/[projectId]/members/index.ts';
3 | import { MemberActionDropdown } from './MemberActionDropdown.tsx';
4 | import { MemberRoleBadge } from './MemberRoleBadge.tsx';
5 |
6 | type ProjectMemberProps = {
7 | member: GetProjectMembersResponse[number];
8 | userId: string;
9 | index: number;
10 | projectId: string;
11 | canManageCurrentProject: boolean;
12 | };
13 |
14 | export function ProjectMemberItem(props: ProjectMemberProps) {
15 | const {
16 | member,
17 | index,
18 | canManageCurrentProject,
19 | userId: currentUserId,
20 | } = props;
21 |
22 | return (
23 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | {member.name}
36 | {member.userId === currentUserId && (
37 |
38 | You
39 |
40 | )}
41 |
42 |
43 | {member.status === 'invited' && (
44 |
45 | Invited
46 |
47 | )}
48 | {member.status === 'rejected' && (
49 |
50 | Rejected
51 |
52 | )}
53 |
54 |
55 |
56 | {member.invitedEmail}
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | {canManageCurrentProject && (
66 |
71 | )}
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/src/db/schema/email-logs.ts:
--------------------------------------------------------------------------------
1 | import { sql } from 'drizzle-orm';
2 | import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
3 | import { projectApiKeys, projects } from './projects';
4 |
5 | export const allowedEmailLogStatus = [
6 | 'queued',
7 | 'sending',
8 | 'sent',
9 | 'delivered',
10 | 'opened',
11 | 'clicked',
12 | 'soft-bounced',
13 | 'bounced',
14 | 'complained',
15 | 'error',
16 | 'rejected',
17 | ] as const;
18 | export type AllowedEmailLogStatus = (typeof allowedEmailLogStatus)[number];
19 |
20 | export const emailLogs = sqliteTable(
21 | 'email_logs',
22 | {
23 | id: text('id').unique().primaryKey(),
24 | messageId: text('message_id'),
25 | projectId: text('project_id')
26 | .notNull()
27 | .references(() => projects.id, {
28 | onDelete: 'cascade',
29 | }),
30 | apiKeyId: text('api_key_id').references(() => projectApiKeys.id, {
31 | onDelete: 'set null',
32 | }),
33 | from: text('from').notNull(),
34 | to: text('to').notNull(),
35 | replyTo: text('reply_to'),
36 | subject: text('subject').notNull(),
37 | text: text('text'),
38 | html: text('html'),
39 | status: text('status', {
40 | enum: allowedEmailLogStatus,
41 | }).notNull(),
42 | sendAt: integer('send_at', { mode: 'timestamp' }),
43 | createdAt: integer('created_at', { mode: 'timestamp' })
44 | .notNull()
45 | .default(sql`(unixepoch())`),
46 | updatedAt: integer('updated_at', { mode: 'timestamp' })
47 | .notNull()
48 | .default(sql`(unixepoch())`),
49 | },
50 | (emailLogs) => {
51 | return {
52 | messageIdEmailIndex: index('message_id_email_idx').on(
53 | emailLogs.messageId,
54 | emailLogs.to,
55 | ),
56 | projectIdIdx: index('project_id_idx').on(emailLogs.projectId),
57 | emailLogIdProjectIdIdx: index('email_log_id_project_id_idx').on(
58 | emailLogs.id,
59 | emailLogs.projectId,
60 | ),
61 | };
62 | },
63 | );
64 |
65 | export const emailLogEvents = sqliteTable('email_log_events', {
66 | id: text('id').unique().primaryKey(),
67 | emailLogId: text('email_log_id')
68 | .notNull()
69 | .references(() => emailLogs.id, {
70 | onDelete: 'cascade',
71 | }),
72 | projectId: text('project_id')
73 | .notNull()
74 | .references(() => projects.id, {
75 | onDelete: 'cascade',
76 | }),
77 | email: text('email').notNull(),
78 | type: text('type', {
79 | enum: allowedEmailLogStatus,
80 | }).notNull(),
81 | rawResponse: text('raw_response'),
82 | userAgent: text('user_agent'),
83 | ipAddress: text('ip_address'),
84 | link: text('link'),
85 | timestamp: integer('timestamp', { mode: 'timestamp' })
86 | .notNull()
87 | .default(sql`(unixepoch())`),
88 | });
89 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/[projectId]/members/[memberId]/resend.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projectMembers, projects } from '@/db/schema';
3 | import { requireProjectMember } from '@/helpers/project';
4 | import { authenticateUser } from '@/lib/authenticate-user';
5 | import {
6 | type HandleRoute,
7 | type RouteParams,
8 | type ValidateRoute,
9 | handler,
10 | } from '@/lib/handler';
11 | import { HttpError } from '@/lib/http-error';
12 | import { rateLimitMiddleware } from '@/lib/rate-limit';
13 | import { json, jsonWithRateLimit } from '@/lib/response';
14 | import type { APIRoute } from 'astro';
15 | import { and, eq } from 'drizzle-orm';
16 | import Joi from 'joi';
17 |
18 | export interface ResendProjectMemberInviteResponse {
19 | status: 'ok';
20 | }
21 |
22 | export type ResendProjectMemberInviteBody = {};
23 |
24 | export interface ResendProjectMemberInviteRequest
25 | extends RouteParams<
26 | ResendProjectMemberInviteBody,
27 | any,
28 | {
29 | projectId: string;
30 | memberId: string;
31 | }
32 | > {}
33 |
34 | async function validate(params: ResendProjectMemberInviteRequest) {
35 | const paramSchema = Joi.object({
36 | projectId: Joi.string().required(),
37 | memberId: Joi.string().required(),
38 | });
39 |
40 | const { error: paramError } = paramSchema.validate(params.context.params, {
41 | abortEarly: false,
42 | stripUnknown: true,
43 | });
44 |
45 | if (paramError) {
46 | throw paramError;
47 | }
48 |
49 | return params;
50 | }
51 |
52 | async function handle(params: ResendProjectMemberInviteRequest) {
53 | const { currentUserId } = params.context.locals;
54 | const { projectId, memberId } = params.context.params;
55 |
56 | const project = await db.query.projects.findFirst({
57 | where: eq(projects.id, projectId),
58 | });
59 |
60 | if (!project) {
61 | throw new HttpError('not_found', 'Project not found');
62 | }
63 |
64 | await requireProjectMember(currentUserId!, projectId, ['admin', 'manager']);
65 |
66 | const member = await db.query.projectMembers.findFirst({
67 | where: and(
68 | eq(projectMembers.projectId, projectId),
69 | eq(projectMembers.id, memberId),
70 | ),
71 | });
72 | if (!member) {
73 | throw new HttpError('not_found', 'Member not found');
74 | }
75 |
76 | // Send invitation email to the member again and increment the invite count
77 |
78 | return jsonWithRateLimit(
79 | json({
80 | status: 'ok',
81 | }),
82 | params.context,
83 | );
84 | }
85 |
86 | export const PATCH: APIRoute = handler(
87 | handle satisfies HandleRoute,
88 | validate satisfies ValidateRoute,
89 | [rateLimitMiddleware(), authenticateUser],
90 | );
91 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/[projectId]/keys/[keyId]/index.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projectApiKeys, projects } from '@/db/schema';
3 | import type { ProjectApiKey } from '@/db/types';
4 | import { requireProjectMember } from '@/helpers/project';
5 | import { authenticateUser } from '@/lib/authenticate-user';
6 | import {
7 | type HandleRoute,
8 | type RouteParams,
9 | type ValidateRoute,
10 | handler,
11 | } from '@/lib/handler';
12 | import { HttpError } from '@/lib/http-error';
13 | import { rateLimitMiddleware } from '@/lib/rate-limit';
14 | import { json, jsonWithRateLimit } from '@/lib/response';
15 | import type { APIRoute } from 'astro';
16 | import { and, eq } from 'drizzle-orm';
17 | import Joi from 'joi';
18 |
19 | export interface GetProjectApiKeyResponse extends Omit {}
20 |
21 | export interface GetProjectApiKeyQuery {}
22 |
23 | export interface GetProjectApiKeyRequest
24 | extends RouteParams<
25 | any,
26 | GetProjectApiKeyQuery,
27 | {
28 | projectId: string;
29 | keyId: string;
30 | }
31 | > {}
32 |
33 | async function validate(params: GetProjectApiKeyRequest) {
34 | const paramsSchema = Joi.object({
35 | projectId: Joi.string().required(),
36 | keyId: Joi.string().required(),
37 | });
38 |
39 | const { error: paramsError } = paramsSchema.validate(params.context.params, {
40 | abortEarly: false,
41 | stripUnknown: true,
42 | });
43 |
44 | if (paramsError) {
45 | throw paramsError;
46 | }
47 |
48 | return params;
49 | }
50 |
51 | async function handle(params: GetProjectApiKeyRequest) {
52 | const { currentUser } = params.context.locals;
53 | const { context } = params;
54 |
55 | if (!currentUser) {
56 | throw new HttpError('unauthorized', 'Unauthorized');
57 | }
58 |
59 | const { projectId, keyId } = context.params;
60 | const project = await db.query.projects.findFirst({
61 | where: eq(projects.id, projectId),
62 | });
63 |
64 | if (!project) {
65 | throw new HttpError('not_found', 'Project not found');
66 | }
67 |
68 | await requireProjectMember(currentUser.id, projectId);
69 |
70 | const apiKey = await db.query.projectApiKeys.findFirst({
71 | where: and(
72 | eq(projectApiKeys.projectId, projectId),
73 | eq(projectApiKeys.id, keyId),
74 | ),
75 | columns: {
76 | key: false,
77 | },
78 | });
79 | if (!apiKey) {
80 | throw new HttpError('not_found', 'API key not found');
81 | }
82 |
83 | return jsonWithRateLimit(
84 | json(apiKey),
85 | params.context,
86 | );
87 | }
88 |
89 | export const GET: APIRoute = handler(
90 | handle satisfies HandleRoute,
91 | validate satisfies ValidateRoute,
92 | [rateLimitMiddleware(), authenticateUser],
93 | );
94 |
--------------------------------------------------------------------------------
/src/components/ProjectIdentities/ProjectIdentityDNSTable.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { CopyableTableField } from './CopyableTableField';
3 | import type { ProjectIdentityRecord } from '@/db/types';
4 |
5 | type ProjectIdentityDNSTableProps = {
6 | records: ProjectIdentityRecord[];
7 | };
8 |
9 | export function ProjectIdentityDNSTable(props: ProjectIdentityDNSTableProps) {
10 | const { records = [] } = props;
11 |
12 | return (
13 |
14 |
15 |
16 |
17 | Type
18 |
19 |
20 | Name
21 |
22 |
23 | Value
24 |
25 |
26 | Priority
27 |
28 |
29 | TTL
30 |
31 |
32 | Status
33 |
34 |
35 |
36 |
37 | {records?.map((record, counter) => {
38 | const status = record.status.replace('-', ' ');
39 |
40 | return (
41 |
42 |
43 | {record.type}
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | {record.priority}
53 |
54 |
55 | {record.ttl}
56 |
57 |
58 | {status}
59 |
60 |
61 | );
62 | })}
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/ProjectMembers/LeaveProjectButton.tsx:
--------------------------------------------------------------------------------
1 | import { httpDelete } from '@/lib/http.ts';
2 | import { queryClient } from '@/utils/query-client.ts';
3 | import { useMutation } from '@tanstack/react-query';
4 | import { useState } from 'react';
5 | import { toast } from 'sonner';
6 | import {
7 | AlertDialog,
8 | AlertDialogAction,
9 | AlertDialogCancel,
10 | AlertDialogContent,
11 | AlertDialogDescription,
12 | AlertDialogFooter,
13 | AlertDialogHeader,
14 | AlertDialogTitle,
15 | AlertDialogTrigger,
16 | } from '../Interface/AlertDialog.tsx';
17 | import { Button } from '../Interface/Button.tsx';
18 |
19 | type LeaveProjectButtonProps = {
20 | projectId: string;
21 | };
22 |
23 | export function LeaveProjectButton(props: LeaveProjectButtonProps) {
24 | const { projectId } = props;
25 |
26 | const leaveProject = useMutation(
27 | {
28 | mutationKey: ['leave-project', projectId],
29 | mutationFn: async () => {
30 | return httpDelete(`/api/v1/projects/${projectId}/members/leave`, {});
31 | },
32 | },
33 | queryClient,
34 | );
35 |
36 | return (
37 |
38 |
39 |
44 | Leave project
45 |
46 |
47 |
48 | {
51 | e.preventDefault();
52 | }}
53 | >
54 |
55 | Leave Project
56 |
57 | This action cannot be undone. This will permanently remove you from
58 | the project.
59 |
60 |
61 |
62 |
63 | Cancel
64 | {
66 | toast.promise(leaveProject.mutateAsync(), {
67 | loading: 'Leaving project..',
68 | success: (data) => {
69 | window.setTimeout(() => {
70 | window.location.href = '/projects';
71 | }, 700);
72 |
73 | return 'Project left';
74 | },
75 | error: (e) => {
76 | return e?.message || 'Failed to leave project';
77 | },
78 | });
79 | }}
80 | >
81 | Leave
82 |
83 |
84 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/[projectId]/identities/[identityId]/index.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projectIdentities, projects } from '@/db/schema';
3 | import type { ProjectIdentity } from '@/db/types';
4 | import { requireProjectMember } from '@/helpers/project';
5 | import { authenticateUser } from '@/lib/authenticate-user';
6 | import {
7 | type HandleRoute,
8 | type RouteParams,
9 | type ValidateRoute,
10 | handler,
11 | } from '@/lib/handler';
12 | import { HttpError } from '@/lib/http-error';
13 | import { rateLimitMiddleware } from '@/lib/rate-limit';
14 | import { json, jsonWithRateLimit } from '@/lib/response';
15 | import type { APIRoute } from 'astro';
16 | import { and, eq } from 'drizzle-orm';
17 | import Joi from 'joi';
18 |
19 | export interface GetProjectIdentityResponse
20 | extends Omit {}
21 |
22 | export interface GetProjectIdentityQuery {}
23 |
24 | export interface GetProjectIdentityRequest
25 | extends RouteParams<
26 | any,
27 | GetProjectIdentityQuery,
28 | {
29 | projectId: string;
30 | identityId: string;
31 | }
32 | > {}
33 |
34 | async function validate(params: GetProjectIdentityRequest) {
35 | const paramsSchema = Joi.object({
36 | projectId: Joi.string().required(),
37 | identityId: Joi.string().required(),
38 | });
39 |
40 | const { error: paramsError } = paramsSchema.validate(params.context.params, {
41 | abortEarly: false,
42 | stripUnknown: true,
43 | });
44 |
45 | if (paramsError) {
46 | throw paramsError;
47 | }
48 |
49 | return params;
50 | }
51 |
52 | async function handle(params: GetProjectIdentityRequest) {
53 | const { context } = params;
54 | const { currentUser } = params.context.locals;
55 |
56 | if (!currentUser) {
57 | throw new HttpError('unauthorized', 'Unauthorized');
58 | }
59 |
60 | const { projectId, identityId } = context.params;
61 | const project = await db.query.projects.findFirst({
62 | where: eq(projects.id, projectId),
63 | });
64 |
65 | if (!project) {
66 | throw new HttpError('not_found', 'Project not found');
67 | }
68 |
69 | await requireProjectMember(currentUser.id, projectId);
70 |
71 | const identity = await db.query.projectIdentities.findFirst({
72 | where: and(
73 | eq(projectIdentities.id, identityId),
74 | eq(projectIdentities.projectId, projectId),
75 | ),
76 | columns: {
77 | configurationSetName: false,
78 | },
79 | });
80 |
81 | if (!identity) {
82 | throw new HttpError('not_found', 'Identity not found');
83 | }
84 |
85 | return jsonWithRateLimit(json(identity), context);
86 | }
87 |
88 | export const GET: APIRoute = handler(
89 | handle satisfies HandleRoute,
90 | validate satisfies ValidateRoute,
91 | [rateLimitMiddleware(), authenticateUser],
92 | );
93 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/[projectId]/index.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import {
3 | type AllowedProjectMemberRole,
4 | type AllowedProjectMemberStatus,
5 | projects,
6 | } from '@/db/schema';
7 | import type { Project } from '@/db/types';
8 | import { requireProjectMember } from '@/helpers/project';
9 | import { authenticateUser } from '@/lib/authenticate-user';
10 | import {
11 | type HandleRoute,
12 | type RouteParams,
13 | type ValidateRoute,
14 | handler,
15 | } from '@/lib/handler';
16 | import { HttpError } from '@/lib/http-error';
17 | import { rateLimitMiddleware } from '@/lib/rate-limit';
18 | import { json, jsonWithRateLimit } from '@/lib/response';
19 | import type { APIRoute } from 'astro';
20 | import { eq } from 'drizzle-orm';
21 | import Joi from 'joi';
22 |
23 | export interface GetProjectResponse extends Project {
24 | memberId: string;
25 | role: AllowedProjectMemberRole;
26 | status: AllowedProjectMemberStatus;
27 | canManage: boolean;
28 | isConfigurationComplete: boolean;
29 | }
30 |
31 | export interface GetProjectRequest
32 | extends RouteParams<
33 | any,
34 | any,
35 | {
36 | projectId: string;
37 | }
38 | > {}
39 |
40 | async function validate(params: GetProjectRequest) {
41 | const paramsSchema = Joi.object({
42 | projectId: Joi.string().required(),
43 | });
44 |
45 | const { error: paramsError } = paramsSchema.validate(params.context.params, {
46 | abortEarly: false,
47 | stripUnknown: true,
48 | });
49 |
50 | if (paramsError) {
51 | throw paramsError;
52 | }
53 |
54 | return params;
55 | }
56 |
57 | async function handle(params: GetProjectRequest) {
58 | const { context } = params;
59 | const { currentUser } = params.context.locals;
60 |
61 | if (!currentUser) {
62 | throw new HttpError('unauthorized', 'Unauthorized');
63 | }
64 |
65 | const { projectId } = context.params;
66 | const project = await db.query.projects.findFirst({
67 | where: eq(projects.id, projectId),
68 | });
69 |
70 | if (!project) {
71 | throw new HttpError('not_found', 'Project not found');
72 | }
73 |
74 | const member = await requireProjectMember(currentUser.id, projectId);
75 |
76 | const { secretAccessKey, accessKeyId, region } = project;
77 | const isConfigurationComplete = Boolean(
78 | secretAccessKey && accessKeyId && region,
79 | );
80 |
81 | return jsonWithRateLimit(
82 | json({
83 | ...project,
84 | status: member.status,
85 | role: member.role,
86 | memberId: member.id,
87 | canManage: ['manager', 'admin'].includes(member.role),
88 | isConfigurationComplete,
89 | }),
90 | context,
91 | );
92 | }
93 |
94 | export const GET: APIRoute = handler(
95 | handle satisfies HandleRoute,
96 | validate satisfies ValidateRoute,
97 | [rateLimitMiddleware(), authenticateUser],
98 | );
99 |
--------------------------------------------------------------------------------
/src/pages/api/v1/auth/login.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { users } from '@/db/schema';
3 | import {
4 | type HandleRoute,
5 | type RouteParams,
6 | type ValidateRoute,
7 | handler,
8 | } from '@/lib/handler';
9 | import { verifyPassword } from '@/lib/hash';
10 | import { HttpError } from '@/lib/http-error';
11 | import { createToken } from '@/lib/jwt';
12 | import { rateLimit, rateLimitMiddleware } from '@/lib/rate-limit';
13 | import { json, jsonWithRateLimit } from '@/lib/response';
14 | import type { APIRoute } from 'astro';
15 | import { eq } from 'drizzle-orm';
16 | import Joi from 'joi';
17 |
18 | export interface V1LoginResponse {
19 | token: string;
20 | }
21 |
22 | type LoginBody = {
23 | email: string;
24 | password: string;
25 | };
26 |
27 | export interface V1LoginRequest extends RouteParams {}
28 |
29 | async function validate(params: V1LoginRequest) {
30 | const schema = Joi.object({
31 | email: Joi.string().email().trim().lowercase().required(),
32 | password: Joi.string().trim().min(8).max(25).required(),
33 | });
34 |
35 | const { error, value } = schema.validate(params.body, {
36 | abortEarly: false,
37 | stripUnknown: true,
38 | });
39 |
40 | if (error) {
41 | throw error;
42 | }
43 |
44 | return {
45 | ...params,
46 | body: value,
47 | };
48 | }
49 |
50 | async function handle(params: V1LoginRequest) {
51 | const { body, context } = params;
52 | const { email, password } = body;
53 |
54 | const associatedUser = await db.query.users.findFirst({
55 | where: eq(users.email, email),
56 | columns: {
57 | id: true,
58 | email: true,
59 | password: true,
60 | verifiedAt: true,
61 | authProvider: true,
62 | },
63 | });
64 |
65 | if (!associatedUser) {
66 | throw new HttpError('bad_request', 'Invalid email or password');
67 | }
68 |
69 | const isValidPassword = await verifyPassword(
70 | password,
71 | associatedUser.password,
72 | );
73 | if (!isValidPassword) {
74 | throw new HttpError('bad_request', 'Invalid email or password');
75 | }
76 |
77 | if (associatedUser.authProvider !== 'email') {
78 | throw new HttpError(
79 | 'bad_request',
80 | `Please login with ${associatedUser.authProvider}.`,
81 | );
82 | }
83 |
84 | if (!associatedUser.verifiedAt) {
85 | throw new HttpError('user_not_verified', 'User is not verified');
86 | }
87 |
88 | const token = await createToken({
89 | id: associatedUser.id,
90 | email: associatedUser.email,
91 | });
92 |
93 | return jsonWithRateLimit(json({ token }), context);
94 | }
95 |
96 | export const POST: APIRoute = handler(
97 | handle satisfies HandleRoute,
98 | validate satisfies ValidateRoute,
99 | [rateLimitMiddleware()],
100 | );
101 |
--------------------------------------------------------------------------------
/src/components/AuthenticationFlow/PendingVerificationMessage.tsx:
--------------------------------------------------------------------------------
1 | import { httpPost } from '@/lib/http';
2 | import type { SendVerificationEmailBody } from '@/pages/api/v1/auth/send-verification-email';
3 | import { queryClient } from '@/utils/query-client';
4 | import { useMutation } from '@tanstack/react-query';
5 | import { useState } from 'react';
6 | import { toast } from 'sonner';
7 |
8 | type PendingVerificationMessageProps = {
9 | email: string;
10 | };
11 |
12 | export function PendingVerificationMessage(
13 | props: PendingVerificationMessageProps,
14 | ) {
15 | const { email } = props;
16 | const [isEmailResent, setIsEmailResent] = useState(false);
17 |
18 | const sendVerificationEmail = useMutation(
19 | {
20 | mutationKey: ['send-verification-email'],
21 | mutationFn: (body: SendVerificationEmailBody) => {
22 | return httpPost('/api/v1/auth/send-verification-email', body);
23 | },
24 | onSuccess: () => {
25 | setIsEmailResent(true);
26 | },
27 | },
28 | queryClient,
29 | );
30 |
31 | const resendVerificationEmail = () => {
32 | toast.promise(sendVerificationEmail.mutateAsync({ email }), {
33 | loading: 'Sending the email ..',
34 | success: 'Verification email has been sent!',
35 | error: (error) => {
36 | return error?.message || 'Something went wrong.';
37 | },
38 | });
39 | };
40 |
41 | const isLoading = sendVerificationEmail.status === 'pending';
42 |
43 | return (
44 |
45 |
46 | Verify your email address
47 |
48 |
49 |
50 | We have sent you an email at{' '}
51 | {email} . Please click
52 | the link to verify your account. This link will expire shortly, so
53 | please verify soon!
54 |
55 |
56 |
57 |
58 | {!isEmailResent && (
59 | <>
60 | {isLoading &&
Sending the email ..
}
61 | {!isLoading && (
62 |
63 | Please make sure to check your spam folder. If you still don't
64 | have the email click to{' '}
65 |
70 | resend verification email.
71 |
72 |
73 | )}
74 | >
75 | )}
76 |
77 | {isEmailResent && (
78 |
Verification email has been sent!
79 | )}
80 |
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/ProjectEmails/EmailEventTable.tsx:
--------------------------------------------------------------------------------
1 | import type { GetProjectEmailResponse } from '@/pages/api/v1/projects/[projectId]/emails/[emailId]/index';
2 | import { detect } from 'detect-browser';
3 | import { DateTime } from 'luxon';
4 | import { Fragment } from 'react';
5 | import { EmailPreviewTabs } from './EmailPreviewTabs';
6 |
7 | type EmailEventTableProps = {
8 | events: GetProjectEmailResponse['events'];
9 | };
10 |
11 | export function EmailEventTable(props: EmailEventTableProps) {
12 | let { events = [] } = props;
13 | events = events.sort((a, b) => {
14 | return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
15 | });
16 |
17 | return (
18 |
19 |
Email Events
20 |
21 | {events.map((event) => {
22 | const timestamp = DateTime.fromJSDate(
23 | new Date(event.timestamp),
24 | ).toFormat('dd LLL yyyy, HH:mm');
25 | const status = event.type.replace('-', ' ');
26 | const isLast = events.indexOf(event) === events.length - 1;
27 | const browser = event?.userAgent && detect(event.userAgent);
28 |
29 | return (
30 |
31 |
32 |
33 |
34 | {status}
35 |
36 |
37 | {timestamp}
38 |
39 |
40 |
41 | {browser && (
42 |
43 | From
44 |
45 | {browser.name} ({browser.version})
46 |
47 | on
48 | {browser.os}
49 | {browser.type === 'bot' && (
50 | (bot)
51 | )}
52 | {event?.type === 'clicked' && (
53 | <>
54 | , clicked
55 |
56 | {event?.link || 'unknown'}
57 |
58 | >
59 | )}
60 |
61 | )}
62 |
63 | {!isLast && }
64 |
65 | );
66 | })}
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/components/ProjectEmails/ProjectEmailDetails.tsx:
--------------------------------------------------------------------------------
1 | import { httpGet } from '@/lib/http';
2 | import type { GetProjectEmailResponse } from '@/pages/api/v1/projects/[projectId]/emails/[emailId]/index';
3 | import { queryClient } from '@/utils/query-client';
4 | import { useQuery } from '@tanstack/react-query';
5 | import { Box } from 'lucide-react';
6 | import { DateTime } from 'luxon';
7 | import { PageError } from '../Errors/PageError';
8 | import { LoadingMessage } from '../LoadingMessage';
9 | import { EmailEventTable } from './EmailEventTable';
10 | import { EmailPreviewTabs } from './EmailPreviewTabs';
11 |
12 | type ProjectEmailDetailsProps = {
13 | projectId: string;
14 | emailId: string;
15 | };
16 |
17 | export function ProjectEmailDetails(props: ProjectEmailDetailsProps) {
18 | const { projectId, emailId } = props;
19 |
20 | const { data, error } = useQuery(
21 | {
22 | queryKey: ['project-emails', projectId, emailId],
23 | queryFn: () => {
24 | return httpGet(
25 | `/api/v1/projects/${projectId}/emails/${emailId}`,
26 | );
27 | },
28 | },
29 | queryClient,
30 | );
31 |
32 | if (error && !data) {
33 | return (
34 |
38 | );
39 | }
40 |
41 | if (!data) {
42 | return ;
43 | }
44 |
45 | const status = data.status.replace('-', ' ');
46 | const createdAt = DateTime.fromJSDate(new Date(data.createdAt)).toRelative();
47 |
48 | return (
49 |
50 |
51 |
52 |
53 |
54 |
55 | Email
56 |
{data.to}
57 |
58 |
59 |
60 |
61 |
62 |
From
63 |
{data.from}
64 |
65 |
66 |
To
67 |
{data.to}
68 |
69 |
70 |
Subject
71 |
{data.subject}
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/create.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projectMembers, projects } from '@/db/schema';
3 | import type { Project } from '@/db/types';
4 | import { authenticateUser } from '@/lib/authenticate-user';
5 | import {
6 | type HandleRoute,
7 | type RouteParams,
8 | type ValidateRoute,
9 | handler,
10 | } from '@/lib/handler';
11 | import { HttpError } from '@/lib/http-error';
12 | import { newId } from '@/lib/new-id';
13 | import { rateLimitMiddleware } from '@/lib/rate-limit';
14 | import { json, jsonWithRateLimit } from '@/lib/response';
15 | import { isValidTimezone } from '@/utils/timezone';
16 | import type { APIRoute } from 'astro';
17 | import Joi from 'joi';
18 |
19 | export interface CreateProjectResponse
20 | extends Pick {}
21 |
22 | export type CreateProjectBody = Pick;
23 | export interface CreateProjectRequest extends RouteParams {}
24 |
25 | async function validate(params: CreateProjectRequest) {
26 | const schema = Joi.object({
27 | name: Joi.string().trim().min(3).required(),
28 | timezone: Joi.string().trim().required(),
29 | url: Joi.string().trim().uri().required(),
30 | });
31 |
32 | const { error, value } = schema.validate(params.body, {
33 | abortEarly: false,
34 | stripUnknown: true,
35 | });
36 |
37 | if (error) {
38 | throw error;
39 | }
40 |
41 | if (!isValidTimezone(value.timezone)) {
42 | throw new HttpError('validation_error', 'Invalid timezone');
43 | }
44 |
45 | return {
46 | ...params,
47 | body: value,
48 | };
49 | }
50 |
51 | async function handle(params: CreateProjectRequest) {
52 | const { body } = params;
53 | const { name, timezone, url } = body;
54 | const { currentUserId, currentUser } = params.context.locals;
55 |
56 | const projectId = newId('project');
57 |
58 | const project = await db
59 | .insert(projects)
60 | .values({
61 | id: projectId,
62 | creatorId: currentUserId!,
63 | name,
64 | url,
65 | timezone,
66 | createdAt: new Date(),
67 | updatedAt: new Date(),
68 | })
69 | .returning({
70 | id: projects.id,
71 | name: projects.name,
72 | url: projects.url,
73 | timezone: projects.timezone,
74 | });
75 |
76 | const memberId = newId('projectMember');
77 | await db.insert(projectMembers).values({
78 | id: memberId,
79 | projectId,
80 | userId: currentUserId!,
81 | invitedEmail: currentUser?.email!,
82 | role: 'admin',
83 | status: 'joined',
84 | createdAt: new Date(),
85 | updatedAt: new Date(),
86 | });
87 |
88 | return jsonWithRateLimit(
89 | json(project?.[0]),
90 | params.context,
91 | );
92 | }
93 |
94 | export const POST: APIRoute = handler(
95 | handle satisfies HandleRoute,
96 | validate satisfies ValidateRoute,
97 | [rateLimitMiddleware(), authenticateUser],
98 | );
99 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/[projectId]/keys/[keyId]/delete.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projectApiKeys, projects } from '@/db/schema';
3 | import { requireProjectMember } from '@/helpers/project';
4 | import { authenticateUser } from '@/lib/authenticate-user';
5 | import {
6 | type HandleRoute,
7 | type RouteParams,
8 | type ValidateRoute,
9 | handler,
10 | } from '@/lib/handler';
11 | import { HttpError } from '@/lib/http-error';
12 | import { rateLimitMiddleware } from '@/lib/rate-limit';
13 | import { json, jsonWithRateLimit } from '@/lib/response';
14 | import type { APIRoute } from 'astro';
15 | import { and, eq } from 'drizzle-orm';
16 | import Joi from 'joi';
17 |
18 | export interface DeleteApiKeyResponse {
19 | status: 'ok';
20 | }
21 |
22 | export type DeleteApiKeyBody = {};
23 |
24 | export interface DeleteApiKeyRequest
25 | extends RouteParams<
26 | DeleteApiKeyBody,
27 | any,
28 | {
29 | projectId: string;
30 | keyId: string;
31 | }
32 | > {}
33 |
34 | async function validate(params: DeleteApiKeyRequest) {
35 | const paramsSchema = Joi.object({
36 | projectId: Joi.string().required(),
37 | keyId: Joi.string().required(),
38 | });
39 |
40 | const { error: paramsError } = paramsSchema.validate(params.context.params, {
41 | abortEarly: false,
42 | stripUnknown: true,
43 | });
44 |
45 | if (paramsError) {
46 | throw paramsError;
47 | }
48 |
49 | return params;
50 | }
51 |
52 | async function handle(params: DeleteApiKeyRequest) {
53 | const { currentUser } = params.context.locals;
54 | const { context } = params;
55 |
56 | if (!currentUser) {
57 | throw new HttpError('unauthorized', 'Unauthorized');
58 | }
59 |
60 | const { projectId, keyId } = context.params;
61 | const project = await db.query.projects.findFirst({
62 | where: eq(projects.id, projectId),
63 | });
64 |
65 | if (!project) {
66 | throw new HttpError('not_found', 'Project not found');
67 | }
68 |
69 | await requireProjectMember(currentUser.id, projectId, ['admin', 'manager']);
70 |
71 | const apiKey = await db.query.projectApiKeys.findFirst({
72 | where: and(
73 | eq(projectApiKeys.projectId, projectId),
74 | eq(projectApiKeys.id, keyId),
75 | ),
76 | });
77 |
78 | if (!apiKey) {
79 | throw new HttpError('not_found', 'API Key not found');
80 | }
81 |
82 | await db
83 | .delete(projectApiKeys)
84 | .where(
85 | and(
86 | eq(projectApiKeys.projectId, projectId),
87 | eq(projectApiKeys.id, keyId),
88 | ),
89 | );
90 |
91 | return jsonWithRateLimit(
92 | json({
93 | status: 'ok',
94 | }),
95 | context,
96 | );
97 | }
98 |
99 | export const DELETE: APIRoute = handler(
100 | handle satisfies HandleRoute,
101 | validate satisfies ValidateRoute,
102 | [rateLimitMiddleware(), authenticateUser],
103 | );
104 |
--------------------------------------------------------------------------------
/src/pages/api/v1/auth/verify-account.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { users } from '@/db/schema';
3 | import {
4 | type HandleRoute,
5 | type RouteParams,
6 | type ValidateRoute,
7 | handler,
8 | } from '@/lib/handler';
9 | import { HttpError } from '@/lib/http-error';
10 | import { createToken } from '@/lib/jwt';
11 | import { rateLimitMiddleware } from '@/lib/rate-limit';
12 | import { json, jsonWithRateLimit } from '@/lib/response';
13 | import type { APIRoute } from 'astro';
14 | import { eq } from 'drizzle-orm';
15 | import Joi from 'joi';
16 |
17 | export interface SendVerificationEmailResponse {
18 | token: string;
19 | }
20 |
21 | export type SendVerificationEmailBody = {
22 | code: string;
23 | };
24 |
25 | export interface SendVerificationEmailRequest
26 | extends RouteParams {}
27 |
28 | async function validate(params: SendVerificationEmailRequest) {
29 | const schema = Joi.object({
30 | code: Joi.string().required(),
31 | });
32 |
33 | const { error, value } = schema.validate(params.body, {
34 | abortEarly: false,
35 | stripUnknown: true,
36 | });
37 |
38 | if (error) {
39 | throw error;
40 | }
41 |
42 | return {
43 | ...params,
44 | body: value,
45 | };
46 | }
47 |
48 | async function handle({ body, context }: SendVerificationEmailRequest) {
49 | const { code } = body;
50 |
51 | const associatedUser = await db.query.users.findFirst({
52 | where: eq(users.verificationCode, code),
53 | });
54 |
55 | if (!associatedUser) {
56 | throw new HttpError(
57 | 'not_found',
58 | 'No user associated with this verification code',
59 | );
60 | }
61 |
62 | if (associatedUser.verifiedAt) {
63 | throw new HttpError('bad_request', 'User is already verified');
64 | }
65 |
66 | const { verificationCodeAt } = associatedUser;
67 | if (!verificationCodeAt) {
68 | throw new HttpError('bad_request', 'Invalid verification code');
69 | }
70 |
71 | // Verification code expires after 24 hours
72 | if (
73 | new Date(verificationCodeAt).getTime() + 24 * 60 * 60 * 1000 <
74 | Date.now()
75 | ) {
76 | throw new HttpError('bad_request', 'Verification code expired');
77 | }
78 |
79 | await db
80 | .update(users)
81 | .set({
82 | verifiedAt: new Date(),
83 | verificationCode: null,
84 | verificationCodeAt: null,
85 | updatedAt: new Date(),
86 | })
87 | .where(eq(users.id, associatedUser.id));
88 |
89 | const token = await createToken({
90 | id: associatedUser.id,
91 | email: associatedUser.email,
92 | });
93 |
94 | return jsonWithRateLimit(
95 | json({ token }),
96 | context,
97 | );
98 | }
99 |
100 | export const POST: APIRoute = handler(
101 | handle satisfies HandleRoute,
102 | validate satisfies ValidateRoute,
103 | [rateLimitMiddleware()],
104 | );
105 |
--------------------------------------------------------------------------------
/src/pages/projects/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Layout from '@/layouts/Layout.astro';
3 | import { projectApi } from '@/api/project';
4 |
5 | const { currentUser } = Astro.locals;
6 | if (!currentUser) {
7 | return Astro.redirect('/login');
8 | }
9 |
10 | const projectClient = projectApi(Astro);
11 | const { response: projects, error: projectError } =
12 | await projectClient.listProjects();
13 | ---
14 |
15 |
16 |
17 |
18 | {
19 | !projectError && projects?.length === 0 && (
20 |
33 | )
34 | }
35 |
36 | {
37 | !projectError && projects && projects?.length > 0 && (
38 |
39 |
40 |
41 | Projects
42 |
43 |
Please select a project
44 |
45 |
72 |
76 | +
77 | New Project
78 |
79 |
80 | )
81 | }
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/src/components/ProjectApiKeys/ProjectApiKeyDetails.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query';
2 | import { Key } from 'lucide-react';
3 | import { DateTime } from 'luxon';
4 | import { httpGet } from '@/lib/http';
5 | import type { GetProjectApiKeyResponse } from '@/pages/api/v1/projects/[projectId]/keys/[keyId]/index';
6 | import { queryClient } from '@/utils/query-client';
7 | import { PageError } from '../Errors/PageError';
8 | import { LoadingMessage } from '../LoadingMessage';
9 |
10 | type ProjectApiKeyDetailsProps = {
11 | projectId: string;
12 | keyId: string;
13 | };
14 |
15 | export function ProjectApiKeyDetails(props: ProjectApiKeyDetailsProps) {
16 | const { projectId, keyId } = props;
17 |
18 | const { data: apiKeyDetails, error } = useQuery(
19 | {
20 | queryKey: ['project-api-keys', projectId, keyId],
21 | queryFn: () => {
22 | return httpGet(
23 | `/api/v1/projects/${projectId}/keys/${keyId}`,
24 | );
25 | },
26 | },
27 | queryClient,
28 | );
29 |
30 | if (error && !apiKeyDetails) {
31 | return (
32 |
36 | );
37 | }
38 |
39 | if (!apiKeyDetails) {
40 | return ;
41 | }
42 |
43 | const createdAt = DateTime.fromJSDate(
44 | new Date(apiKeyDetails.createdAt),
45 | ).toRelative();
46 | const lastUsedAt =
47 | apiKeyDetails?.lastUsedAt &&
48 | DateTime.fromJSDate(new Date(apiKeyDetails.lastUsedAt)).toRelative();
49 |
50 | return (
51 | <>
52 |
53 |
54 |
55 |
56 |
57 | Api Key
58 |
{apiKeyDetails.name}
59 |
60 |
61 |
62 |
63 |
64 |
Created At
65 | {createdAt}
66 |
67 |
68 |
Status
69 |
70 | {apiKeyDetails.status}
71 |
72 |
73 |
74 |
Last Used
75 |
76 | {lastUsedAt || 'Never'}
77 |
78 |
79 |
80 |
Usage Count
81 |
82 | {apiKeyDetails?.usageCount || 0}
83 |
84 |
85 |
86 | >
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/[projectId]/members/index.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projectMembers, projects, users } from '@/db/schema';
3 | import type { ProjectMember } from '@/db/types';
4 | import { requireProjectMember } from '@/helpers/project';
5 | import { authenticateUser } from '@/lib/authenticate-user';
6 | import {
7 | type HandleRoute,
8 | type RouteParams,
9 | type ValidateRoute,
10 | handler,
11 | } from '@/lib/handler';
12 | import { HttpError } from '@/lib/http-error';
13 | import { rateLimitMiddleware } from '@/lib/rate-limit';
14 | import { json, jsonWithRateLimit } from '@/lib/response';
15 | import type { APIRoute } from 'astro';
16 | import { eq, inArray } from 'drizzle-orm';
17 | import Joi from 'joi';
18 |
19 | export type GetProjectMembersResponse = (ProjectMember & {
20 | name: string;
21 | })[];
22 |
23 | export interface GetProjectMembersRequest
24 | extends RouteParams<
25 | any,
26 | any,
27 | {
28 | projectId: string;
29 | }
30 | > {}
31 |
32 | async function validate(params: GetProjectMembersRequest) {
33 | const paramsSchema = Joi.object({
34 | projectId: Joi.string().required(),
35 | });
36 |
37 | const { error: paramsError } = paramsSchema.validate(params.context.params, {
38 | abortEarly: false,
39 | stripUnknown: true,
40 | });
41 |
42 | if (paramsError) {
43 | throw paramsError;
44 | }
45 |
46 | return params;
47 | }
48 |
49 | async function handle(params: GetProjectMembersRequest) {
50 | const { context } = params;
51 | const { currentUser } = params.context.locals;
52 |
53 | if (!currentUser) {
54 | throw new HttpError('unauthorized', 'Unauthorized');
55 | }
56 |
57 | const { projectId } = context.params;
58 | const project = await db.query.projects.findFirst({
59 | where: eq(projects.id, projectId),
60 | });
61 |
62 | if (!project) {
63 | throw new HttpError('not_found', 'Project not found');
64 | }
65 |
66 | await requireProjectMember(currentUser.id, projectId);
67 |
68 | const members = await db.query.projectMembers.findMany({
69 | where: eq(projectMembers.projectId, projectId),
70 | });
71 |
72 | const userIds = members
73 | .filter((member) => member?.userId)
74 | .map((member) => member.userId) as string[];
75 | const associatedUsers = await db
76 | .select({
77 | id: users.id,
78 | name: users.name,
79 | })
80 | .from(users)
81 | .where(inArray(users.id, userIds));
82 |
83 | const enrichedMembers = members.map((member) => {
84 | const user = associatedUsers.find((u) => u.id === member.userId);
85 |
86 | return {
87 | ...member,
88 | name: user?.name || 'Unknown',
89 | };
90 | });
91 |
92 | return jsonWithRateLimit(
93 | json(enrichedMembers),
94 | context,
95 | );
96 | }
97 |
98 | export const GET: APIRoute = handler(
99 | handle satisfies HandleRoute,
100 | validate satisfies ValidateRoute,
101 | [rateLimitMiddleware(), authenticateUser],
102 | );
103 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/[projectId]/update.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projects } from '@/db/schema';
3 | import type { Project } from '@/db/types';
4 | import { requireProjectMember } from '@/helpers/project';
5 | import { authenticateUser } from '@/lib/authenticate-user';
6 | import {
7 | type HandleRoute,
8 | type RouteParams,
9 | type ValidateRoute,
10 | handler,
11 | } from '@/lib/handler';
12 | import { HttpError } from '@/lib/http-error';
13 | import { rateLimitMiddleware } from '@/lib/rate-limit';
14 | import { json, jsonWithRateLimit } from '@/lib/response';
15 | import { createSESServiceClient, isValidConfiguration } from '@/lib/ses';
16 | import type { APIRoute } from 'astro';
17 | import { eq } from 'drizzle-orm';
18 | import Joi from 'joi';
19 |
20 | export interface UpdateProjectResponse {
21 | status: 'ok';
22 | }
23 |
24 | export type UpdateProjectBody = Pick;
25 |
26 | export interface UpdateProjectRequest
27 | extends RouteParams<
28 | UpdateProjectBody,
29 | any,
30 | {
31 | projectId: string;
32 | }
33 | > {}
34 |
35 | async function validate(params: UpdateProjectRequest) {
36 | const schema = Joi.object({
37 | name: Joi.string().trim().min(3).required(),
38 | timezone: Joi.string().trim().required(),
39 | url: Joi.string().trim().uri().required(),
40 | });
41 |
42 | const { error, value } = schema.validate(params.body, {
43 | abortEarly: false,
44 | stripUnknown: true,
45 | });
46 |
47 | if (error) {
48 | throw error;
49 | }
50 |
51 | const paramsSchema = Joi.object({
52 | projectId: Joi.string().required(),
53 | });
54 |
55 | const { error: paramsError } = paramsSchema.validate(params.context.params, {
56 | abortEarly: false,
57 | stripUnknown: true,
58 | });
59 |
60 | if (paramsError) {
61 | throw paramsError;
62 | }
63 |
64 | return {
65 | ...params,
66 | body: value,
67 | };
68 | }
69 |
70 | async function handle(params: UpdateProjectRequest) {
71 | const { body } = params;
72 | const { projectId } = params.context.params;
73 | const { currentUserId } = params.context.locals;
74 |
75 | const project = await db.query.projects.findFirst({
76 | where: eq(projects.id, projectId),
77 | });
78 |
79 | if (!project) {
80 | throw new HttpError('not_found', 'Project not found');
81 | }
82 |
83 | await requireProjectMember(currentUserId!, projectId, ['admin', 'manager']);
84 |
85 | const { name, url, timezone } = body;
86 | await db
87 | .update(projects)
88 | .set({
89 | name,
90 | url,
91 | timezone,
92 | })
93 | .where(eq(projects.id, projectId));
94 |
95 | return jsonWithRateLimit(
96 | json({
97 | status: 'ok',
98 | }),
99 | params.context,
100 | );
101 | }
102 |
103 | export const PATCH: APIRoute = handler(
104 | handle satisfies HandleRoute,
105 | validate satisfies ValidateRoute,
106 | [rateLimitMiddleware(), authenticateUser],
107 | );
108 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/index.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import {
3 | type AllowedProjectMemberRole,
4 | type AllowedProjectMemberStatus,
5 | projectMembers,
6 | projects,
7 | } from '@/db/schema';
8 | import type { Project } from '@/db/types';
9 | import { authenticateUser } from '@/lib/authenticate-user';
10 | import {
11 | type HandleRoute,
12 | type RouteParams,
13 | type ValidateRoute,
14 | handler,
15 | } from '@/lib/handler';
16 | import { HttpError } from '@/lib/http-error';
17 | import { rateLimitMiddleware } from '@/lib/rate-limit';
18 | import { json, jsonWithRateLimit } from '@/lib/response';
19 | import type { APIRoute } from 'astro';
20 | import { and, eq, inArray, or } from 'drizzle-orm';
21 |
22 | export interface ListProjectsResponse
23 | extends Pick {
24 | memberId: string;
25 | role: AllowedProjectMemberRole;
26 | status: AllowedProjectMemberStatus;
27 | }
28 |
29 | export interface ListProjectsRequest extends RouteParams {}
30 |
31 | async function validate(params: ListProjectsRequest) {
32 | return params;
33 | }
34 |
35 | async function handle(params: ListProjectsRequest) {
36 | const { currentUser } = params.context.locals;
37 | if (!currentUser) {
38 | throw new HttpError('unauthorized', 'Unauthorized');
39 | }
40 |
41 | const associatedMembers = await db.query.projectMembers.findMany({
42 | where: and(
43 | or(
44 | eq(projectMembers.userId, currentUser.id),
45 | eq(projectMembers.invitedEmail, currentUser.email),
46 | ),
47 | inArray(projectMembers.status, ['joined', 'invited']),
48 | ),
49 | columns: {
50 | id: true,
51 | status: true,
52 | projectId: true,
53 | role: true,
54 | },
55 | });
56 |
57 | const projectIds = associatedMembers.map((member) => member.projectId);
58 |
59 | let allProjects: {
60 | id: string;
61 | name: string;
62 | url: string;
63 | }[] = [];
64 |
65 | if (projectIds.length > 0) {
66 | allProjects = await db.query.projects.findMany({
67 | where: inArray(projects.id, projectIds),
68 | columns: {
69 | id: true,
70 | name: true,
71 | url: true,
72 | },
73 | });
74 | }
75 |
76 | const enrichedProjects: ListProjectsResponse[] = [];
77 | for (const project of allProjects) {
78 | const projectMember = associatedMembers.find(
79 | (pm) => pm.projectId === project.id,
80 | );
81 | if (!projectMember) {
82 | continue;
83 | }
84 |
85 | enrichedProjects.push({
86 | ...project,
87 | memberId: projectMember.id,
88 | role: projectMember.role,
89 | status: projectMember.status,
90 | });
91 | }
92 |
93 | return jsonWithRateLimit(
94 | json(enrichedProjects),
95 | params.context,
96 | );
97 | }
98 |
99 | export const GET: APIRoute = handler(
100 | handle satisfies HandleRoute,
101 | validate satisfies ValidateRoute,
102 | [rateLimitMiddleware(), authenticateUser],
103 | );
104 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/[projectId]/keys/create.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projectApiKeys, projects } from '@/db/schema';
3 | import {
4 | requireProjectConfiguration,
5 | requireProjectMember,
6 | } from '@/helpers/project';
7 | import { authenticateUser } from '@/lib/authenticate-user';
8 | import {
9 | type HandleRoute,
10 | type RouteParams,
11 | type ValidateRoute,
12 | handler,
13 | } from '@/lib/handler';
14 | import { HttpError } from '@/lib/http-error';
15 | import { newApiKey, newId } from '@/lib/new-id';
16 | import { rateLimitMiddleware } from '@/lib/rate-limit';
17 | import { json, jsonWithRateLimit } from '@/lib/response';
18 | import type { APIRoute } from 'astro';
19 | import { eq } from 'drizzle-orm';
20 | import Joi from 'joi';
21 |
22 | export interface CreateProjectApiKeyResponse {
23 | key: string;
24 | }
25 |
26 | export type CreateProjectApiKeyBody = {
27 | name: string;
28 | };
29 |
30 | export interface CreateProjectApiKeyRequest
31 | extends RouteParams<
32 | CreateProjectApiKeyBody,
33 | any,
34 | {
35 | projectId: string;
36 | }
37 | > {}
38 |
39 | async function validate(params: CreateProjectApiKeyRequest) {
40 | const paramSchema = Joi.object({
41 | projectId: Joi.string().required(),
42 | });
43 |
44 | const { error: paramError } = paramSchema.validate(params.context.params, {
45 | abortEarly: false,
46 | stripUnknown: true,
47 | });
48 |
49 | if (paramError) {
50 | throw paramError;
51 | }
52 |
53 | const schema = Joi.object({
54 | name: Joi.string().min(3).required(),
55 | });
56 |
57 | const { error, value } = schema.validate(params.body, {
58 | abortEarly: false,
59 | stripUnknown: true,
60 | });
61 |
62 | if (error) {
63 | throw error;
64 | }
65 |
66 | return {
67 | ...params,
68 | body: value,
69 | };
70 | }
71 |
72 | async function handle(params: CreateProjectApiKeyRequest) {
73 | const { body } = params;
74 | const { currentUserId } = params.context.locals;
75 | const { projectId } = params.context.params;
76 |
77 | const project = await db.query.projects.findFirst({
78 | where: eq(projects.id, projectId),
79 | });
80 |
81 | if (!project) {
82 | throw new HttpError('not_found', 'Project not found');
83 | }
84 |
85 | await requireProjectMember(currentUserId!, projectId, ['admin']);
86 | await requireProjectConfiguration(project);
87 |
88 | const apiKeyId = newId('key');
89 | const key = newApiKey();
90 |
91 | await db.insert(projectApiKeys).values({
92 | id: apiKeyId,
93 | name: body.name,
94 | projectId,
95 | creatorId: currentUserId!,
96 | key,
97 | createdAt: new Date(),
98 | updatedAt: new Date(),
99 | });
100 |
101 | return jsonWithRateLimit(
102 | json({
103 | key,
104 | }),
105 | params.context,
106 | );
107 | }
108 |
109 | export const POST: APIRoute = handler(
110 | handle satisfies HandleRoute,
111 | validate satisfies ValidateRoute,
112 | [rateLimitMiddleware(), authenticateUser],
113 | );
114 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/[projectId]/emails/[emailId]/index.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { emailLogEvents, emailLogs, projects } from '@/db/schema';
3 | import type { EmailLog, EmailLogEvent } from '@/db/types';
4 | import { requireProjectMember } from '@/helpers/project';
5 | import { authenticateUser } from '@/lib/authenticate-user';
6 | import {
7 | type HandleRoute,
8 | type RouteParams,
9 | type ValidateRoute,
10 | handler,
11 | } from '@/lib/handler';
12 | import { HttpError } from '@/lib/http-error';
13 | import { rateLimitMiddleware } from '@/lib/rate-limit';
14 | import { json, jsonWithRateLimit } from '@/lib/response';
15 | import type { APIRoute } from 'astro';
16 | import { and, eq } from 'drizzle-orm';
17 | import Joi from 'joi';
18 |
19 | export interface GetProjectEmailResponse
20 | extends Omit {
21 | events: Omit[];
22 | }
23 |
24 | export interface GetProjectEmailQuery {}
25 |
26 | export interface GetProjectEmailRequest
27 | extends RouteParams<
28 | any,
29 | GetProjectEmailQuery,
30 | {
31 | projectId: string;
32 | emailId: string;
33 | }
34 | > {}
35 |
36 | async function validate(params: GetProjectEmailRequest) {
37 | const paramsSchema = Joi.object({
38 | projectId: Joi.string().required(),
39 | emailId: Joi.string().required(),
40 | });
41 |
42 | const { error: paramsError } = paramsSchema.validate(params.context.params, {
43 | abortEarly: false,
44 | stripUnknown: true,
45 | });
46 |
47 | if (paramsError) {
48 | throw paramsError;
49 | }
50 |
51 | return params;
52 | }
53 |
54 | async function handle(params: GetProjectEmailRequest) {
55 | const { context } = params;
56 | const { currentUser } = params.context.locals;
57 |
58 | if (!currentUser) {
59 | throw new HttpError('unauthorized', 'Unauthorized');
60 | }
61 |
62 | const { projectId, emailId } = context.params;
63 | const project = await db.query.projects.findFirst({
64 | where: eq(projects.id, projectId),
65 | });
66 |
67 | if (!project) {
68 | throw new HttpError('not_found', 'Project not found');
69 | }
70 |
71 | await requireProjectMember(currentUser.id, projectId);
72 |
73 | const emailLog = await db.query.emailLogs.findFirst({
74 | where: and(eq(emailLogs.id, emailId), eq(emailLogs.projectId, projectId)),
75 | columns: {
76 | messageId: false,
77 | apiKeyId: false,
78 | },
79 | });
80 |
81 | if (!emailLog) {
82 | throw new HttpError('not_found', 'Email not found');
83 | }
84 |
85 | const emailEvents = await db.query.emailLogEvents.findMany({
86 | where: eq(emailLogEvents.emailLogId, emailLog.id),
87 | columns: {
88 | rawResponse: false,
89 | },
90 | });
91 |
92 | return jsonWithRateLimit(
93 | json({
94 | ...emailLog,
95 | events: emailEvents,
96 | }),
97 | context,
98 | );
99 | }
100 |
101 | export const GET: APIRoute = handler(
102 | handle satisfies HandleRoute,
103 | validate satisfies ValidateRoute,
104 | [rateLimitMiddleware(), authenticateUser],
105 | );
106 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/invitations/[inviteId]/index.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projectMembers, projects } from '@/db/schema';
3 | import type { AllowedProjectMemberRole } from '@/db/types';
4 | import { authenticateUser } from '@/lib/authenticate-user';
5 | import {
6 | type HandleRoute,
7 | type RouteParams,
8 | type ValidateRoute,
9 | handler,
10 | } from '@/lib/handler';
11 | import { HttpError } from '@/lib/http-error';
12 | import { rateLimitMiddleware } from '@/lib/rate-limit';
13 | import { json, jsonWithRateLimit } from '@/lib/response';
14 | import type { APIRoute } from 'astro';
15 | import { and, eq } from 'drizzle-orm';
16 | import Joi from 'joi';
17 |
18 | export interface GetProjectMemberInviteInfoResponse {
19 | project: {
20 | id: string;
21 | name: string;
22 | };
23 | invitedMember: {
24 | id: string;
25 | email: string;
26 | role: AllowedProjectMemberRole;
27 | };
28 | }
29 |
30 | export type GetProjectMemberInviteInfoBody = {};
31 |
32 | export interface GetProjectMemberInviteInfoRequest
33 | extends RouteParams<
34 | GetProjectMemberInviteInfoBody,
35 | any,
36 | {
37 | inviteId: string;
38 | }
39 | > {}
40 |
41 | async function validate(params: GetProjectMemberInviteInfoRequest) {
42 | const paramSchema = Joi.object({
43 | inviteId: Joi.string().required(),
44 | });
45 |
46 | const { error: paramError } = paramSchema.validate(params.context.params, {
47 | abortEarly: false,
48 | stripUnknown: true,
49 | });
50 |
51 | if (paramError) {
52 | throw paramError;
53 | }
54 |
55 | return params;
56 | }
57 |
58 | async function handle(params: GetProjectMemberInviteInfoRequest) {
59 | const { currentUser } = params.context.locals;
60 | const { inviteId } = params.context.params;
61 |
62 | const invitedMember = await db.query.projectMembers.findFirst({
63 | where: and(eq(projectMembers.id, inviteId)),
64 | });
65 |
66 | if (!invitedMember) {
67 | throw new HttpError('not_found', 'Invite not found');
68 | }
69 |
70 | if (invitedMember.status !== 'invited') {
71 | throw new HttpError('forbidden', 'Invite has already been responded');
72 | }
73 |
74 | if (invitedMember.invitedEmail !== currentUser?.email) {
75 | throw new HttpError('forbidden', 'You are not allowed to view this invite');
76 | }
77 |
78 | const project = await db.query.projects.findFirst({
79 | where: eq(projects.id, invitedMember.projectId),
80 | });
81 | if (!project) {
82 | throw new HttpError('not_found', 'Project not found');
83 | }
84 |
85 | return jsonWithRateLimit(
86 | json({
87 | project: {
88 | id: project.id,
89 | name: project.name,
90 | },
91 | invitedMember: {
92 | id: invitedMember.id,
93 | email: invitedMember.invitedEmail,
94 | role: invitedMember.role,
95 | },
96 | }),
97 | params.context,
98 | );
99 | }
100 |
101 | export const GET: APIRoute = handler(
102 | handle satisfies HandleRoute,
103 | validate satisfies ValidateRoute,
104 | [rateLimitMiddleware(), authenticateUser],
105 | );
106 |
--------------------------------------------------------------------------------
/src/pages/api/v1/projects/invitations/[inviteId]/respond.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { projectMembers } from '@/db/schema';
3 | import { authenticateUser } from '@/lib/authenticate-user';
4 | import {
5 | type HandleRoute,
6 | type RouteParams,
7 | type ValidateRoute,
8 | handler,
9 | } from '@/lib/handler';
10 | import { HttpError } from '@/lib/http-error';
11 | import { rateLimitMiddleware } from '@/lib/rate-limit';
12 | import { json, jsonWithRateLimit } from '@/lib/response';
13 | import type { APIRoute } from 'astro';
14 | import { and, eq } from 'drizzle-orm';
15 | import Joi from 'joi';
16 |
17 | export interface RespondProjectMemberInviteResponse {
18 | status: 'ok';
19 | }
20 |
21 | export type RespondProjectMemberInviteBody = {
22 | action: 'accept' | 'reject';
23 | };
24 |
25 | export interface RespondProjectMemberInviteRequest
26 | extends RouteParams<
27 | RespondProjectMemberInviteBody,
28 | any,
29 | {
30 | inviteId: string;
31 | }
32 | > {}
33 |
34 | async function validate(params: RespondProjectMemberInviteRequest) {
35 | const paramSchema = Joi.object({
36 | inviteId: Joi.string().required(),
37 | });
38 |
39 | const { error: paramError } = paramSchema.validate(params.context.params, {
40 | abortEarly: false,
41 | stripUnknown: true,
42 | });
43 |
44 | if (paramError) {
45 | throw paramError;
46 | }
47 |
48 | const bodySchema = Joi.object({
49 | action: Joi.string().valid('accept', 'reject').required(),
50 | });
51 |
52 | const { error: bodyError } = bodySchema.validate(params.body, {
53 | abortEarly: false,
54 | stripUnknown: true,
55 | });
56 |
57 | if (bodyError) {
58 | throw bodyError;
59 | }
60 |
61 | return params;
62 | }
63 |
64 | async function handle(params: RespondProjectMemberInviteRequest) {
65 | const { inviteId } = params.context.params;
66 | const { currentUser } = params.context.locals;
67 |
68 | const invitedMember = await db.query.projectMembers.findFirst({
69 | where: and(eq(projectMembers.id, inviteId)),
70 | });
71 |
72 | if (!invitedMember) {
73 | throw new HttpError('not_found', 'Invite not found');
74 | }
75 |
76 | if (
77 | invitedMember.invitedEmail !== currentUser?.email ||
78 | (invitedMember?.userId && invitedMember.userId !== currentUser?.id)
79 | ) {
80 | throw new HttpError('forbidden', 'Invited email or user does not match');
81 | }
82 |
83 | if (invitedMember.status !== 'invited') {
84 | throw new HttpError('forbidden', 'Invite has already been responded');
85 | }
86 |
87 | const { action } = params.body;
88 |
89 | await db
90 | .update(projectMembers)
91 | .set({
92 | status: action === 'accept' ? 'joined' : 'rejected',
93 | userId: currentUser?.id,
94 | })
95 | .where(eq(projectMembers.id, inviteId));
96 |
97 | return jsonWithRateLimit(
98 | json({
99 | status: 'ok',
100 | }),
101 | params.context,
102 | );
103 | }
104 |
105 | export const PATCH: APIRoute = handler(
106 | handle satisfies HandleRoute,
107 | validate satisfies ValidateRoute,
108 | [rateLimitMiddleware(), authenticateUser],
109 | );
110 |
--------------------------------------------------------------------------------
/src/components/ProjectEmails/ListProjectEmails.tsx:
--------------------------------------------------------------------------------
1 | import { httpGet } from '@/lib/http';
2 | import type { ListProjectEmailsResponse } from '@/pages/api/v1/projects/[projectId]/emails/index';
3 | import { queryClient } from '@/utils/query-client';
4 | import { useQuery } from '@tanstack/react-query';
5 | import { ArrowUpRight, Mail } from 'lucide-react';
6 | import { useEffect, useState } from 'react';
7 | import { EmptyItems } from '../EmptyItems';
8 | import { PageError } from '../Errors/PageError';
9 | import { LoadingMessage } from '../LoadingMessage';
10 | import { Pagination } from '../Pagination';
11 | import { ListProjectEmailsTable } from './ListEmailsTable';
12 |
13 | type ListProjectEmailsProps = {
14 | projectId: string;
15 | };
16 |
17 | export function ListProjectEmails(props: ListProjectEmailsProps) {
18 | const { projectId } = props;
19 |
20 | const [currPage, setCurrPage] = useState(1);
21 | const { data, error } = useQuery(
22 | {
23 | queryKey: ['project-emails', projectId, { currPage }],
24 | queryFn: () => {
25 | return httpGet(
26 | `/api/v1/projects/${projectId}/emails`,
27 | {
28 | currPage,
29 | },
30 | );
31 | },
32 | },
33 | queryClient,
34 | );
35 |
36 | useEffect(() => {
37 | if (!data) {
38 | return;
39 | }
40 |
41 | setCurrPage(data.currPage || 1);
42 | }, [data]);
43 |
44 | if (error && !data) {
45 | return (
46 |
50 | );
51 | }
52 |
53 | if (!data) {
54 | return ;
55 | }
56 |
57 | const emails = data.data;
58 |
59 | return (
60 | <>
61 | {emails.length === 0 && (
62 |
69 | )}
70 |
71 | {emails.length > 0 && (
72 | <>
73 |
86 |
87 |
88 |
89 |
{
95 | setCurrPage(page);
96 | }}
97 | />
98 |
99 |
100 | >
101 | )}
102 | >
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/src/lib/rate-limit.ts:
--------------------------------------------------------------------------------
1 | import { serverConfig } from './config';
2 | import type { MiddlewareRoute } from './handler';
3 | import { RateLimitError } from './http-error';
4 | import { logError, logInfo } from './logger';
5 | import { connectRedis } from './redis';
6 |
7 | const LIMIT_SCRIPT = `
8 | local current
9 | current = tonumber(redis.call("incr", KEYS[1]))
10 | if current == 1 then
11 | redis.call("expire", KEYS[1], ARGV[1])
12 | end
13 | local ttl = redis.call("ttl", KEYS[1])
14 | return {current, ttl}
15 | `;
16 |
17 | export interface RateLimitResponse {
18 | success: boolean;
19 | count: number;
20 | limit: number;
21 | remaining: number;
22 | reset: number;
23 | }
24 |
25 | export interface RateLimitOptions {
26 | requests: number;
27 | timeWindow: number;
28 | prefix?: string;
29 | }
30 |
31 | export async function rateLimit(
32 | identifier: string,
33 | options: RateLimitOptions,
34 | ): Promise {
35 | const { requests, timeWindow, prefix } = options;
36 |
37 | try {
38 | const cache = await connectRedis();
39 | identifier = prefix ? `${prefix}:${identifier}` : identifier;
40 |
41 | let [result, ttl] = (await cache.eval(LIMIT_SCRIPT, {
42 | keys: [identifier],
43 | arguments: [String(timeWindow)],
44 | })) as [number, number];
45 | result = typeof result === 'number' ? result : parseInt(result);
46 |
47 | const remaining = requests - result;
48 |
49 | return {
50 | success: remaining >= 0,
51 | limit: requests,
52 | remaining: Math.max(0, remaining),
53 | count: result,
54 | reset: ttl || 0,
55 | };
56 | } catch (error) {
57 | logError(error, (error as any)?.stack);
58 | return {
59 | success: false,
60 | limit: requests,
61 | remaining: 0,
62 | count: 0,
63 | reset: 0,
64 | };
65 | }
66 | }
67 |
68 | export const DEFAULT_RATE_LIMIT_REQUESTS = 150;
69 | export const DEFAULT_RATE_LIMIT_TIME_WINDOW = 60; // 1 minute
70 |
71 | export function rateLimitMiddleware(
72 | options?: Partial,
73 | ): MiddlewareRoute {
74 | return async (params) => {
75 | const { context } = params;
76 | const ipAddress =
77 | context.clientAddress ||
78 | context.request.headers.get('x-forwarded-for') ||
79 | context.request.headers.get('x-real-ip');
80 |
81 | const { success, remaining, limit, count, reset } = await rateLimit(
82 | ipAddress!,
83 | {
84 | requests: options?.requests || DEFAULT_RATE_LIMIT_REQUESTS,
85 | timeWindow: options?.timeWindow || DEFAULT_RATE_LIMIT_TIME_WINDOW, // 1 minute
86 | prefix: options?.prefix || 'mly-rate-limit',
87 | },
88 | );
89 |
90 | if (serverConfig.isDev) {
91 | logInfo('-- Rate Limit Middleware --');
92 | logInfo(
93 | `Rate limit: ${remaining}/${limit} for ${ipAddress}(count: ${count}). Reset in ${reset} seconds.`,
94 | );
95 | logInfo('-------------------------');
96 | }
97 |
98 | context.locals.rateLimit = {
99 | success,
100 | remaining,
101 | limit,
102 | count,
103 | reset,
104 | };
105 |
106 | if (!success) {
107 | throw new RateLimitError(
108 | 'Too many requests, please try again later.',
109 | limit,
110 | remaining,
111 | reset,
112 | );
113 | }
114 | return params;
115 | };
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/Projects/ProjectNavigation.tsx:
--------------------------------------------------------------------------------
1 | import type { User } from '@/db/types';
2 | import { cn } from '@/utils/classname';
3 | import {
4 | BarChart2,
5 | Box,
6 | Fingerprint,
7 | FolderOpen,
8 | Key,
9 | Mail,
10 | Users2,
11 | } from 'lucide-react';
12 | import { AccountButton } from '../AccountButton';
13 |
14 | type ProjectNavigationProps = {
15 | url: string;
16 | projectName: string;
17 | projectId: string;
18 | identityId?: string;
19 | emailId?: string;
20 | keyId?: string;
21 | currentUser: Pick;
22 | };
23 |
24 | export function ProjectNavigation(props: ProjectNavigationProps) {
25 | const {
26 | url: defaultUrl = '',
27 | projectName,
28 | projectId,
29 | identityId,
30 | emailId,
31 | keyId,
32 | currentUser,
33 | } = props;
34 | const url = new URL(defaultUrl || '');
35 |
36 | const primaryLinks = [
37 | {
38 | name: 'Dashboard',
39 | icon: BarChart2,
40 | href: `/projects/${projectId}/dashboard`,
41 | alts: [],
42 | },
43 | {
44 | name: 'Identity',
45 | icon: Fingerprint,
46 | href: `/projects/${projectId}/identities`,
47 | alts: [
48 | `/projects/${projectId}/identities/new`,
49 | `/projects/${projectId}/identities/${identityId}`,
50 | ],
51 | },
52 | {
53 | name: 'Key',
54 | icon: Key,
55 | href: `/projects/${projectId}/keys`,
56 | alts: [
57 | `/projects/${projectId}/keys/new`,
58 | `/projects/${projectId}/keys/${keyId}`,
59 | ],
60 | },
61 | {
62 | name: 'Email',
63 | icon: Mail,
64 | href: `/projects/${projectId}/emails`,
65 | alts: [`/projects/${projectId}/emails/${emailId}`],
66 | },
67 | {
68 | name: 'Member',
69 | icon: Users2,
70 | href: `/projects/${projectId}/members`,
71 | alts: [],
72 | },
73 | {
74 | name: 'Setting',
75 | icon: Box,
76 | href: `/projects/${projectId}/settings`,
77 | alts: [],
78 | },
79 | ] as const;
80 |
81 | return (
82 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import { MoreHorizontal } from 'lucide-react';
2 | import { usePagination } from '../hooks/use-pagination';
3 | import { cn } from '../utils/classname';
4 | import { formatCommaNumber } from '../utils/number';
5 |
6 | type PaginationProps = {
7 | variant?: 'minimal' | 'default';
8 | totalPages: number;
9 | currPage: number;
10 | perPage: number;
11 | totalCount: number;
12 | isDisabled?: boolean;
13 | onPageChange: (page: number) => void;
14 | };
15 |
16 | export function Pagination(props: PaginationProps) {
17 | const {
18 | variant = 'default',
19 | onPageChange,
20 | totalCount,
21 | totalPages,
22 | currPage,
23 | perPage,
24 | isDisabled = false,
25 | } = props;
26 |
27 | if (!totalPages || totalPages === 1) {
28 | return null;
29 | }
30 |
31 | const pages = usePagination(currPage, totalPages, 5);
32 |
33 | return (
34 |
40 |
41 | {
43 | onPageChange(currPage - 1);
44 | }}
45 | disabled={currPage === 1 || isDisabled}
46 | className='rounded-md border border-zinc-800 px-2 py-1 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40'
47 | >
48 | ←
49 |
50 | {variant === 'default' && (
51 | <>
52 | {pages.map((page, counter) => {
53 | if (page === 'more') {
54 | return (
55 |
59 |
60 |
61 | );
62 | }
63 |
64 | return (
65 | {
69 | onPageChange(page as number);
70 | }}
71 | className={cn(
72 | 'hidden rounded-md border border-zinc-800 px-2 py-1 hover:bg-zinc-800 sm:block',
73 | {
74 | 'opacity-50': currPage === page,
75 | },
76 | )}
77 | >
78 | {page}
79 |
80 | );
81 | })}
82 | >
83 | )}
84 | {
88 | onPageChange(currPage + 1);
89 | }}
90 | >
91 | →
92 |
93 |
94 |
95 | Showing {formatCommaNumber((currPage - 1) * perPage)} to{' '}
96 | {formatCommaNumber((currPage - 1) * perPage + perPage)} of{' '}
97 | {formatCommaNumber(totalCount)} entries
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/src/pages/api/v1/auth/send-verification-email.ts:
--------------------------------------------------------------------------------
1 | import { db } from '@/db';
2 | import { users } from '@/db/schema';
3 | import { sendVerificationEmail } from '@/lib/auth-email';
4 | import {
5 | type HandleRoute,
6 | type RouteParams,
7 | type ValidateRoute,
8 | handler,
9 | } from '@/lib/handler';
10 | import { HttpError } from '@/lib/http-error';
11 | import { rateLimitMiddleware } from '@/lib/rate-limit';
12 | import { json, jsonWithRateLimit } from '@/lib/response';
13 | import type { APIRoute } from 'astro';
14 | import { eq } from 'drizzle-orm';
15 | import Joi from 'joi';
16 | import { v4 as uuidV4 } from 'uuid';
17 |
18 | export interface SendVerificationEmailResponse {
19 | status: 'ok';
20 | }
21 |
22 | export type SendVerificationEmailBody = {
23 | email: string;
24 | };
25 |
26 | export interface SendVerificationEmailRequest
27 | extends RouteParams {}
28 |
29 | async function validate(params: SendVerificationEmailRequest) {
30 | const schema = Joi.object({
31 | email: Joi.string().email().trim().lowercase().required(),
32 | });
33 |
34 | const { error, value } = schema.validate(params.body, {
35 | abortEarly: false,
36 | stripUnknown: true,
37 | });
38 |
39 | if (error) {
40 | throw error;
41 | }
42 |
43 | const associatedUser = await db.query.users.findFirst({
44 | where: eq(users.email, value.email),
45 | columns: {
46 | id: true,
47 | verifiedAt: true,
48 | verificationCode: true,
49 | verificationCodeAt: true,
50 | },
51 | });
52 |
53 | if (!associatedUser) {
54 | throw new HttpError(
55 | 'not_found',
56 | 'No user associated with this email address',
57 | );
58 | }
59 |
60 | if (associatedUser.verifiedAt || !associatedUser.verificationCode) {
61 | throw new HttpError(
62 | 'bad_request',
63 | 'This email address is already verified',
64 | );
65 | }
66 |
67 | if (associatedUser?.verificationCodeAt) {
68 | const verificationCodeAt = new Date(associatedUser.verificationCodeAt);
69 | const now = new Date();
70 | const diff = now.getTime() - verificationCodeAt.getTime();
71 |
72 | // Wait 3 minutes before sending another verification email
73 | if (diff < 3 * 60 * 1000) {
74 | throw new HttpError('bad_request', 'Please wait before requesting again');
75 | }
76 | }
77 |
78 | return {
79 | ...params,
80 | body: value,
81 | };
82 | }
83 |
84 | async function handle({ body, context }: SendVerificationEmailRequest) {
85 | const { email } = body;
86 |
87 | const associatedUser = await db.query.users.findFirst({
88 | where: eq(users.email, email),
89 | columns: {
90 | id: true,
91 | },
92 | });
93 |
94 | const newVerificationCode = uuidV4();
95 | await db
96 | .update(users)
97 | .set({
98 | verificationCode: newVerificationCode,
99 | verificationCodeAt: new Date(),
100 | updatedAt: new Date(),
101 | })
102 | .where(eq(users.id, associatedUser?.id!));
103 |
104 | await sendVerificationEmail(email, newVerificationCode!);
105 |
106 | return jsonWithRateLimit(
107 | json({ status: 'ok' }),
108 | context,
109 | );
110 | }
111 |
112 | export const POST: APIRoute = handler(
113 | handle satisfies HandleRoute,
114 | validate satisfies ValidateRoute,
115 | [rateLimitMiddleware()],
116 | );
117 |
--------------------------------------------------------------------------------