From 5205551ca8ffd99d177a3e668eca052be40611d0 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Tue, 28 Jan 2025 22:29:03 -0800 Subject: [PATCH] Converted admin, auth, me and realtime routes. --- src/app/api/admin/users/route.ts | 39 +++++++++ src/app/api/admin/websites/route.ts | 87 +++++++++++++++++++ src/app/api/auth/login/route.ts | 44 ++++++++++ src/app/api/auth/logout/route.ts | 14 +++ src/app/api/auth/sso/route.ts | 18 ++++ src/app/api/auth/verify/route.ts | 12 +++ src/app/api/me/password/route.ts | 33 +++++++ src/app/api/me/route.ts | 12 +++ src/app/api/me/teams/route.ts | 21 +++++ src/app/api/me/websites/route.ts | 21 +++++ src/app/api/realtime/[websiteId]/route.ts | 30 +++++++ src/app/api/users/[userId]/teams/route.ts | 2 +- src/lib/request.ts | 8 +- src/lib/response.ts | 12 ++- src/pages/api/admin/{users.ts => _users.ts} | 0 .../api/admin/{websites.ts => _websites.ts} | 0 src/pages/api/auth/{login.ts => _login.ts} | 0 src/pages/api/auth/{logout.ts => _logout.ts} | 0 src/pages/api/auth/{sso.ts => _sso.ts} | 0 src/pages/api/auth/{verify.ts => _verify.ts} | 0 src/pages/api/me/{index.ts => _index.ts} | 0 .../api/me/{password.ts => _password.ts} | 0 src/pages/api/me/{teams.ts => _teams.ts} | 0 .../api/me/{websites.ts => _websites.ts} | 0 .../{[websiteId].ts => _[websiteId].ts} | 0 25 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/admin/websites/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/sso/route.ts create mode 100644 src/app/api/auth/verify/route.ts create mode 100644 src/app/api/me/password/route.ts create mode 100644 src/app/api/me/route.ts create mode 100644 src/app/api/me/teams/route.ts create mode 100644 src/app/api/me/websites/route.ts create mode 100644 src/app/api/realtime/[websiteId]/route.ts rename src/pages/api/admin/{users.ts => _users.ts} (100%) rename src/pages/api/admin/{websites.ts => _websites.ts} (100%) rename src/pages/api/auth/{login.ts => _login.ts} (100%) rename src/pages/api/auth/{logout.ts => _logout.ts} (100%) rename src/pages/api/auth/{sso.ts => _sso.ts} (100%) rename src/pages/api/auth/{verify.ts => _verify.ts} (100%) rename src/pages/api/me/{index.ts => _index.ts} (100%) rename src/pages/api/me/{password.ts => _password.ts} (100%) rename src/pages/api/me/{teams.ts => _teams.ts} (100%) rename src/pages/api/me/{websites.ts => _websites.ts} (100%) rename src/pages/api/realtime/{[websiteId].ts => _[websiteId].ts} (100%) diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 00000000..5ed4a8ff --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { parseRequest } from 'lib/request'; +import { json, unauthorized } from 'lib/response'; +import { pagingParams } from 'lib/schema'; +import { canViewUsers } from 'lib/auth'; +import { getUsers } from 'queries/prisma/user'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewUsers(auth))) { + return unauthorized(); + } + + const users = await getUsers( + { + include: { + _count: { + select: { + websiteUser: { + where: { deletedAt: null }, + }, + }, + }, + }, + }, + query, + ); + + return json(users); +} diff --git a/src/app/api/admin/websites/route.ts b/src/app/api/admin/websites/route.ts new file mode 100644 index 00000000..014ef8d5 --- /dev/null +++ b/src/app/api/admin/websites/route.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import { parseRequest } from 'lib/request'; +import { json, unauthorized } from 'lib/response'; +import { pagingParams } from 'lib/schema'; +import { canViewAllWebsites } from 'lib/auth'; +import { getWebsites } from 'queries/prisma/website'; +import { ROLES } from 'lib/constants'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewAllWebsites(auth))) { + return unauthorized(); + } + + const { userId, includeOwnedTeams, includeAllTeams } = query; + + const websites = await getWebsites( + { + where: { + OR: [ + ...(userId && [{ userId }]), + ...(userId && includeOwnedTeams + ? [ + { + team: { + deletedAt: null, + teamUser: { + some: { + role: ROLES.teamOwner, + userId, + }, + }, + }, + }, + ] + : []), + ...(userId && includeAllTeams + ? [ + { + team: { + deletedAt: null, + teamUser: { + some: { + userId, + }, + }, + }, + }, + ] + : []), + ], + }, + include: { + user: { + select: { + username: true, + id: true, + }, + }, + team: { + where: { + deletedAt: null, + }, + include: { + teamUser: { + where: { + role: ROLES.teamOwner, + }, + }, + }, + }, + }, + }, + query, + ); + + return json(websites); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..42d71fcf --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { checkPassword, createSecureToken } from 'next-basics'; +import { redisEnabled } from '@umami/redis-client'; +import { getUserByUsername } from 'queries'; +import { json, unauthorized } from 'lib/response'; +import { parseRequest } from 'lib/request'; +import { saveAuth } from 'lib/auth'; +import { secret } from 'lib/crypto'; +import { ROLES } from 'lib/constants'; + +export async function POST(request: Request) { + const schema = z.object({ + username: z.string(), + password: z.string(), + }); + + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + + if (error) { + return error(); + } + + const { username, password } = body; + + const user = await getUserByUsername(username, { includePassword: true }); + + if (!user || !checkPassword(password, user.password)) { + return unauthorized(); + } + + if (redisEnabled) { + const token = await saveAuth({ userId: user.id }); + + return json({ token, user }); + } + + const token = createSecureToken({ userId: user.id }, secret()); + const { id, role, createdAt } = user; + + return json({ + token, + user: { id, username, role, createdAt, isAdmin: role === ROLES.admin }, + }); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 00000000..ce7ce7f8 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,14 @@ +import { getClient, redisEnabled } from '@umami/redis-client'; +import { ok } from 'lib/response'; + +export async function POST(request: Request) { + if (redisEnabled) { + const redis = getClient(); + + const token = request.headers.get('authorization')?.split(' ')?.[1]; + + await redis.del(token); + } + + return ok(); +} diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts new file mode 100644 index 00000000..e06e403c --- /dev/null +++ b/src/app/api/auth/sso/route.ts @@ -0,0 +1,18 @@ +import { redisEnabled } from '@umami/redis-client'; +import { json } from 'lib/response'; +import { parseRequest } from 'lib/request'; +import { saveAuth } from 'lib/auth'; + +export async function POST(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + if (redisEnabled) { + const token = await saveAuth({ userId: auth.user.id }, 86400); + + return json({ user: auth.user, token }); + } +} diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts new file mode 100644 index 00000000..db62a339 --- /dev/null +++ b/src/app/api/auth/verify/route.ts @@ -0,0 +1,12 @@ +import { parseRequest } from 'lib/request'; +import { json } from 'lib/response'; + +export async function GET(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + return json(auth.user); +} diff --git a/src/app/api/me/password/route.ts b/src/app/api/me/password/route.ts new file mode 100644 index 00000000..39af3d0e --- /dev/null +++ b/src/app/api/me/password/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { parseRequest } from 'lib/request'; +import { json, badRequest } from 'lib/response'; +import { getUser, updateUser } from 'queries/prisma/user'; +import { checkPassword, hashPassword } from 'next-basics'; + +export async function POST(request: Request) { + const schema = z.object({ + currentPassword: z.string(), + newPassword: z.string().min(8), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const userId = auth.user.id; + const { currentPassword, newPassword } = body; + + const user = await getUser(userId, { includePassword: true }); + + if (!checkPassword(currentPassword, user.password)) { + return badRequest('Current password is incorrect'); + } + + const password = hashPassword(newPassword); + + const updated = await updateUser(userId, { password }); + + return json(updated); +} diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 00000000..60a14271 --- /dev/null +++ b/src/app/api/me/route.ts @@ -0,0 +1,12 @@ +import { parseRequest } from 'lib/request'; +import { json } from 'lib/response'; + +export async function GET(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + return json(auth); +} diff --git a/src/app/api/me/teams/route.ts b/src/app/api/me/teams/route.ts new file mode 100644 index 00000000..0624e94f --- /dev/null +++ b/src/app/api/me/teams/route.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { pagingParams } from 'lib/schema'; +import { getUserTeams } from 'queries'; +import { json } from 'lib/response'; +import { parseRequest } from 'lib/request'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const teams = await getUserTeams(auth.user.id, query); + + return json(teams); +} diff --git a/src/app/api/me/websites/route.ts b/src/app/api/me/websites/route.ts new file mode 100644 index 00000000..725ca94b --- /dev/null +++ b/src/app/api/me/websites/route.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { pagingParams } from 'lib/schema'; +import { getUserWebsites } from 'queries'; +import { json } from 'lib/response'; +import { parseRequest } from 'lib/request'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const websites = await getUserWebsites(auth.user.id, query); + + return json(websites); +} diff --git a/src/app/api/realtime/[websiteId]/route.ts b/src/app/api/realtime/[websiteId]/route.ts new file mode 100644 index 00000000..b575ac12 --- /dev/null +++ b/src/app/api/realtime/[websiteId]/route.ts @@ -0,0 +1,30 @@ +import { json, unauthorized } from 'lib/response'; +import { getRealtimeData } from 'queries'; +import { canViewWebsite } from 'lib/auth'; +import { startOfMinute, subMinutes } from 'date-fns'; +import { REALTIME_RANGE } from 'lib/constants'; +import { parseRequest } from 'lib/request'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, query, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { timezone } = query; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const startDate = subMinutes(startOfMinute(new Date()), REALTIME_RANGE); + + const data = await getRealtimeData(websiteId, { startDate, timezone }); + + return json(data); +} diff --git a/src/app/api/users/[userId]/teams/route.ts b/src/app/api/users/[userId]/teams/route.ts index 4eb37a61..329e7413 100644 --- a/src/app/api/users/[userId]/teams/route.ts +++ b/src/app/api/users/[userId]/teams/route.ts @@ -17,7 +17,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user const { userId } = await params; - if (!auth.user.isAdmin && (!userId || auth.user.id !== userId)) { + if (auth.user.id !== userId && !auth.user.isAdmin) { return unauthorized(); } diff --git a/src/lib/request.ts b/src/lib/request.ts index 769a8f21..dc721225 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -13,7 +13,11 @@ export async function getJsonBody(request: Request) { } } -export async function parseRequest(request: Request, schema?: ZodObject) { +export async function parseRequest( + request: Request, + schema?: ZodObject, + options?: { skipAuth: boolean }, +): Promise { const url = new URL(request.url); let query = Object.fromEntries(url.searchParams); let body = await getJsonBody(request); @@ -32,7 +36,7 @@ export async function parseRequest(request: Request, schema?: ZodObject) { } } - const auth = !error ? await checkAuth(request) : null; + const auth = !error && !options?.skipAuth ? await checkAuth(request) : null; if (!error && !auth) { error = () => unauthorized(); diff --git a/src/lib/response.ts b/src/lib/response.ts index 7ed0316e..5e3b020f 100644 --- a/src/lib/response.ts +++ b/src/lib/response.ts @@ -12,14 +12,18 @@ export function badRequest(message?: any) { return Response.json({ error: 'Bad request', message }, { status: 400 }); } -export function notFound(message?: any) { - return Response.json({ error: 'Not found', message, status: 404 }); -} - export function unauthorized(message?: any) { return Response.json({ error: 'Unauthorized', message }, { status: 401 }); } +export function forbidden(message?: any) { + return Response.json({ error: 'Forbidden', message, status: 403 }); +} + +export function notFound(message?: any) { + return Response.json({ error: 'Not found', message, status: 404 }); +} + export function serverError(error?: any) { return Response.json({ error: 'Server error', message: serializeError(error), status: 500 }); } diff --git a/src/pages/api/admin/users.ts b/src/pages/api/admin/_users.ts similarity index 100% rename from src/pages/api/admin/users.ts rename to src/pages/api/admin/_users.ts diff --git a/src/pages/api/admin/websites.ts b/src/pages/api/admin/_websites.ts similarity index 100% rename from src/pages/api/admin/websites.ts rename to src/pages/api/admin/_websites.ts diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/_login.ts similarity index 100% rename from src/pages/api/auth/login.ts rename to src/pages/api/auth/_login.ts diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/_logout.ts similarity index 100% rename from src/pages/api/auth/logout.ts rename to src/pages/api/auth/_logout.ts diff --git a/src/pages/api/auth/sso.ts b/src/pages/api/auth/_sso.ts similarity index 100% rename from src/pages/api/auth/sso.ts rename to src/pages/api/auth/_sso.ts diff --git a/src/pages/api/auth/verify.ts b/src/pages/api/auth/_verify.ts similarity index 100% rename from src/pages/api/auth/verify.ts rename to src/pages/api/auth/_verify.ts diff --git a/src/pages/api/me/index.ts b/src/pages/api/me/_index.ts similarity index 100% rename from src/pages/api/me/index.ts rename to src/pages/api/me/_index.ts diff --git a/src/pages/api/me/password.ts b/src/pages/api/me/_password.ts similarity index 100% rename from src/pages/api/me/password.ts rename to src/pages/api/me/_password.ts diff --git a/src/pages/api/me/teams.ts b/src/pages/api/me/_teams.ts similarity index 100% rename from src/pages/api/me/teams.ts rename to src/pages/api/me/_teams.ts diff --git a/src/pages/api/me/websites.ts b/src/pages/api/me/_websites.ts similarity index 100% rename from src/pages/api/me/websites.ts rename to src/pages/api/me/_websites.ts diff --git a/src/pages/api/realtime/[websiteId].ts b/src/pages/api/realtime/_[websiteId].ts similarity index 100% rename from src/pages/api/realtime/[websiteId].ts rename to src/pages/api/realtime/_[websiteId].ts