feat: create team routes and api auth functions

This commit is contained in:
Puru D
2024-06-26 23:41:41 -05:00
parent 12a90dfcab
commit 26ba4906b8
14 changed files with 246 additions and 25 deletions
+1
View File
@@ -38,6 +38,7 @@ yarn-error.log*
.env
.env*.local
/prisma/enums.ts
/prisma/generated/*
# vercel
.vercel
+2
View File
@@ -52,8 +52,10 @@ yarn-error.log*
/prisma/enums.ts
/prisma/generated/*
/.pnpm-store
# Sentry Config File
.env.sentry-build-plugin
notes.md
+4 -1
View File
@@ -8,6 +8,7 @@
"dist",
"public/pdf.worker.min.js",
"./prisma/enums.ts",
"./prisma/generated/*",
"./src/components/ui/simple-multi-select.tsx"
]
},
@@ -27,6 +28,7 @@
"dist",
"public/pdf.worker.min.js",
"./prisma/enums.ts",
"./prisma/generated/*",
"./src/components/ui/simple-multi-select.tsx"
]
},
@@ -43,7 +45,8 @@
".next",
"dist",
"public/pdf.worker.min.js",
"./prisma/enums.ts"
"./prisma/enums.ts",
"./prisma/generated/*"
]
}
}
@@ -0,0 +1,21 @@
/*
Warnings:
- You are about to drop the column `secret` on the `ApiKey` table. All the data in the column will be lost.
- A unique constraint covering the columns `[hashedToken]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail.
- Added the required column `hashedToken` to the `ApiKey` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "ApiKey_secret_key";
-- DropIndex
DROP INDEX "ApiKey_userId_key";
-- AlterTable
ALTER TABLE "ApiKey" DROP COLUMN "secret",
ADD COLUMN "hashedToken" TEXT NOT NULL,
ADD COLUMN "lastUsed" TIMESTAMP(3);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_hashedToken_key" ON "ApiKey"("hashedToken");
+9 -9
View File
@@ -60,7 +60,7 @@ model User {
companies Member[]
passkeys Passkey[]
verificationToken VerificationToken[]
apiKey ApiKey?
apiKeys ApiKey[]
lastSignedIn DateTime @default(now())
identityProvider String?
@@ -1066,15 +1066,15 @@ model BillingCustomer {
}
model ApiKey {
id String @id @default(cuid())
secret String @unique // scrypt hashed secret
userId String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
active Boolean @default(true)
hashedToken String @unique // scrypt hashed token
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsed DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId])
@@index([userId])
}
+92
View File
@@ -0,0 +1,92 @@
import { verifySecureHash } from "@/lib/crypto";
import { ApiError } from "@/server/api/error";
import { db } from "@/server/db";
import type { Context } from "hono";
type Headers = Context["req"]["header"];
const verifyBearerToken = async (headers: Headers) => {
const bearerToken = headers("Authorization");
if (!bearerToken) {
throw new ApiError({
code: "UNAUTHORIZED",
message: "No bearer token provided",
});
}
const token = bearerToken.split(" ")[1];
if (!token) {
throw new ApiError({
code: "UNAUTHORIZED",
message: "No bearer token provided",
});
}
const [id, hashedToken] = token.split("_");
if (!id || !hashedToken) {
throw new ApiError({
code: "UNAUTHORIZED",
message: "Invalid token provided",
});
}
const apiKey = await db.apiKey.findUnique({
where: { id, active: true },
select: { id: true, hashedToken: true, user: true },
});
if (!apiKey) {
throw new ApiError({
code: "UNAUTHORIZED",
message: "Invalid token provided",
});
}
const isValid = await verifySecureHash(apiKey.hashedToken, hashedToken);
if (!isValid) {
throw new ApiError({
code: "UNAUTHORIZED",
message: "Invalid token provided",
});
}
await db.apiKey.update({
where: { id },
data: { lastUsed: new Date() },
});
return apiKey.user;
};
export const withCompanyAuth = async (id: string, headers: Headers) => {
const user = await verifyBearerToken(headers);
const member = await db.member.findFirst({
where: {
userId: user.id,
companyId: id,
},
});
if (!member) {
throw new ApiError({
code: "FORBIDDEN",
message: "You do not have access to this company",
});
}
const company = await db.company.findUnique({
where: { id },
});
if (!company) {
throw new ApiError({
code: "NOT_FOUND",
message: "Company not found",
});
}
return { user, company, member };
};
+61
View File
@@ -0,0 +1,61 @@
import { ErrorResponses } from "@/server/api/error";
import type { PublicAPI } from "@/server/api/hono";
import { createRoute, z } from "@hono/zod-openapi";
import type { Context } from "hono";
export const RequestSchema = z.object({
id: z
.string()
.cuid()
.openapi({
description: "Company ID",
param: {
name: "id",
in: "path",
},
example: "clxwbok580000i7nge8nm1ry0",
}),
});
export const ResponseSchema = z
.object({
id: z.string().cuid().openapi({
description: "Company ID",
example: "clxwbok580000i7nge8nm1ry0",
}),
})
.openapi("Company");
const route = createRoute({
method: "get",
path: "api/v1/companies/:id",
request: { params: RequestSchema },
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "Get a company by ID",
},
...ErrorResponses,
},
});
const getOne = (app: PublicAPI) => {
app.openapi(route, (c: Context) => {
const params = c.req.param();
return c.json(
{
id: params.id as string,
},
200,
);
});
};
export default getOne;
export type ResponseSchemaType = z.infer<typeof ResponseSchema>;
+50 -10
View File
@@ -1,18 +1,42 @@
import { ErrorResponses } from "@/server/api/error";
import { withCompanyAuth } from "@/server/api/auth";
import { ApiError, ErrorResponses } from "@/server/api/error";
import type { PublicAPI } from "@/server/api/hono";
import { createRoute, z } from "@hono/zod-openapi";
import type { Context, HonoRequest } from "hono";
import type { Context } from "hono";
export const RequestSchema = z.object({
id: z
.string()
.cuid()
.openapi({
description: "Company ID",
param: {
name: "id",
in: "path",
},
example: "clxwbok580000i7nge8nm1ry0",
}),
});
export const ResponseSchema = z
.object({
id: z.string().cuid().openapi({
description: "Company ID",
example: "clxwbok580000i7nge8nm1ry0",
}),
})
.openapi("Company");
const route = createRoute({
method: "get",
path: "/v1/companies/:cid",
path: "api/v1/companies/:id",
request: { params: RequestSchema },
responses: {
200: {
content: {
"application/json": {
schema: z.object({
message: z.string(),
}),
schema: ResponseSchema,
},
},
description: "Get a company by ID",
@@ -21,13 +45,29 @@ const route = createRoute({
...ErrorResponses,
},
});
const getOne = (app: PublicAPI) => {
app.openapi(route, async (c: Context) => {
const req: HonoRequest = await c.req;
console.log({ req });
return c.json({ message: "TODO: implement this endpoint" });
const params = c.req.param();
const headers = c.req.header;
const id = params.id as string;
const { company } = await withCompanyAuth(id, headers);
if (!company) {
throw new ApiError({
code: "NOT_FOUND",
message: "Company not found",
});
}
return c.json(
{
id: company.id,
},
200,
);
});
};
export default getOne;
export type ResponseSchemaType = z.infer<typeof ResponseSchema>;
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Context, HonoRequest } from "hono";
const route = createRoute({
method: "post",
path: "/v1/companies/:cid/teams",
path: "/v1/companies/:id/teams",
responses: {
200: {
content: {
@@ -5,7 +5,7 @@ import type { Context, HonoRequest } from "hono";
const route = createRoute({
method: "get",
path: "/v1/companies/:cid/teams",
path: "/v1/companies/:id/teams",
responses: {
200: {
content: {
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Context, HonoRequest } from "hono";
const route = createRoute({
method: "get",
path: "/v1/companies/:cid/teams/:tid",
path: "/v1/companies/:id/teams/:teamId",
responses: {
200: {
content: {
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Context, HonoRequest } from "hono";
const route = createRoute({
method: "put",
path: "/v1/companies/:cid/teams/:tid",
path: "/v1/companies/:id/teams/:teamId",
responses: {
200: {
content: {
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Context, HonoRequest } from "hono";
const route = createRoute({
method: "put",
path: "/v1/companies/:cid",
path: "/v1/companies/:id",
responses: {
200: {
content: {
+1
View File
@@ -13,6 +13,7 @@
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
"strictNullChecks": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],