diff --git a/README.md b/README.md index 1c4c48a4..86513473 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ - Manage Apps - Manage Scopes - Manage Roles + - View Logs - Localization ### Screenshots diff --git a/admin-panel/app/Setup.tsx b/admin-panel/app/Setup.tsx index 7da5f383..ff872235 100644 --- a/admin-panel/app/Setup.tsx +++ b/admin-panel/app/Setup.tsx @@ -102,6 +102,9 @@ const LayoutSetup = ({ children } : PropsWithChildren) => { const locale = useCurrentLocale() const { logoutRedirect } = useAuth() + const configs = useSignalValue(configSignal) + const showLogs = configs?.ENABLE_SIGN_IN_LOG || configs?.ENABLE_SMS_LOG || configs?.ENABLE_EMAIL_LOG + useEffect( () => { localStorage.setItem( @@ -168,6 +171,15 @@ const LayoutSetup = ({ children } : PropsWithChildren) => { > {t('layout.scopes')} + {!!showLogs && ( + + {t('layout.logs')} + + )} { + const { id } = useParams() + + const t = useTranslations() + + const [log, setLog] = useState() + const { acquireToken } = useAuth() + + useEffect( + () => { + const getLog = async () => { + const token = await acquireToken() + const data = await proxyTool.sendNextRequest({ + endpoint: `/api/logs/email/${id}`, + method: 'GET', + token, + }) + setLog(data.log) + } + + getLog() + }, + [acquireToken, id], + ) + + if (!log) return null + + return ( +
+ +
+ + + {t('common.property')} + {t('common.value')} + + + + {t('logs.receiver')} + + {log.receiver} + + + + {t('logs.response')} + + {log.response} + + + + {t('logs.content')} + +
+ + + + {t('common.createdAt')} + {log.createdAt} UTC + + + {t('common.updatedAt')} + {log.updatedAt} UTC + + +
+
+
+ ) +} + +export default Page diff --git a/admin-panel/app/[lang]/logs/page.tsx b/admin-panel/app/[lang]/logs/page.tsx new file mode 100644 index 00000000..c4928ca9 --- /dev/null +++ b/admin-panel/app/[lang]/logs/page.tsx @@ -0,0 +1,257 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { + Pagination, + Spinner, Table, +} from 'flowbite-react' +import { + useEffect, useMemo, useState, +} from 'react' +import { useAuth } from '@melody-auth/react' +import PageTitle from 'components/PageTitle' +import { configSignal } from 'signals' +import useSignalValue from 'app/useSignalValue' +import { proxyTool } from 'tools' +import ConfigBooleanValue from 'components/ConfigBooleanValue' +import ViewLink from 'components/ViewLink' +import useCurrentLocale from 'hooks/useCurrentLocale' + +const PageSize = 10 + +const Page = () => { + const t = useTranslations() + const locale = useCurrentLocale() + + const configs = useSignalValue(configSignal) + const { acquireToken } = useAuth() + + const [emailLogs, setEmailLogs] = useState([]) + const [emailLogPageNumber, setEmailLogPageNumber] = useState(1) + const [emailLogCount, setEmailLogCount] = useState(0) + + const [smsLogs, setSmsLogs] = useState([]) + const [smsLogPageNumber, setSmsLogPageNumber] = useState(1) + const [smsLogCount, setSmsLogCount] = useState(0) + + const [signInLogs, setSignInLogs] = useState([]) + const [signInLogPageNumber, setSignInLogPageNumber] = useState(1) + const [signInLogCount, setSignInLogCount] = useState(0) + + const emailLogTotalPages = useMemo( + () => Math.ceil(emailLogCount / PageSize), + [emailLogCount], + ) + + const smsLogTotalPages = useMemo( + () => Math.ceil(smsLogCount / PageSize), + [smsLogCount], + ) + + const signInLogTotalPages = useMemo( + () => Math.ceil(signInLogCount / PageSize), + [signInLogCount], + ) + + useEffect( + () => { + const getEmailLogs = async () => { + const token = await acquireToken() + const baseUrl = `/api/logs/email?page_size=${PageSize}&page_number=${emailLogPageNumber}` + const data = await proxyTool.sendNextRequest({ + endpoint: baseUrl, + method: 'GET', + token, + }) + setEmailLogs(data.logs) + setEmailLogCount(data.count) + } + + getEmailLogs() + }, + [acquireToken, emailLogPageNumber], + ) + + useEffect( + () => { + const getSmsLogs = async () => { + const token = await acquireToken() + const baseUrl = `/api/logs/sms?page_size=${PageSize}&page_number=${smsLogPageNumber}` + const data = await proxyTool.sendNextRequest({ + endpoint: baseUrl, + method: 'GET', + token, + }) + setSmsLogs(data.logs) + setSmsLogCount(data.count) + } + + getSmsLogs() + }, + [acquireToken, smsLogPageNumber], + ) + + useEffect( + () => { + const getSignInLogs = async () => { + const token = await acquireToken() + const baseUrl = `/api/logs/sign-in?page_size=${PageSize}&page_number=${signInLogPageNumber}` + const data = await proxyTool.sendNextRequest({ + endpoint: baseUrl, + method: 'GET', + token, + }) + setSignInLogs(data.logs) + setSignInLogCount(data.count) + } + + getSignInLogs() + }, + [acquireToken, signInLogPageNumber], + ) + + const handleEmailLogPageChange = (page: number) => { + setEmailLogPageNumber(page) + } + + const handleSmsLogPageChange = (page: number) => { + setSmsLogPageNumber(page) + } + + const handleSignInLogPageChange = (page: number) => { + setSignInLogPageNumber(page) + } + + if (!configs) return + + return ( +
+ {configs.ENABLE_EMAIL_LOG && ( + <> + + + + {t('logs.receiver')} + {t('logs.success')} + {t('logs.time')} + + + + {emailLogs.map((log) => ( + + {log.receiver} + + {log.createdAt} + + + + + ))} + +
+ {emailLogTotalPages > 1 && ( + + )} + + )} + {configs.ENABLE_SMS_LOG && ( + <> + + + + {t('logs.receiver')} + {t('logs.success')} + {t('logs.time')} + + + + {smsLogs.map((log) => ( + + {log.receiver} + + {log.createdAt} + + + + + ))} + +
+ {smsLogTotalPages > 1 && ( + + )} + + )} + {configs.ENABLE_SIGN_IN_LOG && ( + <> + + + + {t('logs.userId')} + {t('logs.time')} + + + + {signInLogs.map((log) => ( + + {log.userId} + {log.createdAt} + + + + + ))} + +
+ {signInLogTotalPages > 1 && ( + + )} + + )} +
+ ) +} + +export default Page diff --git a/admin-panel/app/[lang]/logs/sign-in/[id]/page.tsx b/admin-panel/app/[lang]/logs/sign-in/[id]/page.tsx new file mode 100644 index 00000000..cbb39333 --- /dev/null +++ b/admin-panel/app/[lang]/logs/sign-in/[id]/page.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useAuth } from '@melody-auth/react' +import { Table } from 'flowbite-react' +import { useTranslations } from 'next-intl' +import { useParams } from 'next/navigation' +import { + useEffect, useState, +} from 'react' +import { proxyTool } from 'tools' +import PageTitle from 'components/PageTitle' + +const Page = () => { + const { id } = useParams() + + const t = useTranslations() + + const [log, setLog] = useState() + const { acquireToken } = useAuth() + + useEffect( + () => { + const getLog = async () => { + const token = await acquireToken() + const data = await proxyTool.sendNextRequest({ + endpoint: `/api/logs/sign-in/${id}`, + method: 'GET', + token, + }) + setLog(data.log) + } + + getLog() + }, + [acquireToken, id], + ) + + if (!log) return null + + return ( +
+ +
+ + + {t('common.property')} + {t('common.value')} + + + + {t('logs.userId')} + + {log.userId} + + + + {t('logs.ip')} + + {log.ip} + + + + {t('logs.detail')} + + {log.detail} + + + + {t('common.createdAt')} + {log.createdAt} UTC + + + {t('common.updatedAt')} + {log.updatedAt} UTC + + +
+
+
+ ) +} + +export default Page diff --git a/admin-panel/app/[lang]/logs/sms/[id]/page.tsx b/admin-panel/app/[lang]/logs/sms/[id]/page.tsx new file mode 100644 index 00000000..f53517d8 --- /dev/null +++ b/admin-panel/app/[lang]/logs/sms/[id]/page.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useAuth } from '@melody-auth/react' +import { Table } from 'flowbite-react' +import { useTranslations } from 'next-intl' +import { useParams } from 'next/navigation' +import { + useEffect, useState, +} from 'react' +import { proxyTool } from 'tools' +import PageTitle from 'components/PageTitle' + +const Page = () => { + const { id } = useParams() + + const t = useTranslations() + + const [log, setLog] = useState() + const { acquireToken } = useAuth() + + useEffect( + () => { + const getLog = async () => { + const token = await acquireToken() + const data = await proxyTool.sendNextRequest({ + endpoint: `/api/logs/sms/${id}`, + method: 'GET', + token, + }) + setLog(data.log) + } + + getLog() + }, + [acquireToken, id], + ) + + if (!log) return null + + return ( +
+ +
+ + + {t('common.property')} + {t('common.value')} + + + + {t('logs.receiver')} + + {log.receiver} + + + + {t('logs.response')} + + {log.response} + + + + {t('logs.content')} + + {log.content} + + + + {t('common.createdAt')} + {log.createdAt} UTC + + + {t('common.updatedAt')} + {log.updatedAt} UTC + + +
+
+
+ ) +} + +export default Page diff --git a/admin-panel/app/api/logs/email/[id]/route.ts b/admin-panel/app/api/logs/email/[id]/route.ts new file mode 100644 index 00000000..a55ad9bb --- /dev/null +++ b/admin-panel/app/api/logs/email/[id]/route.ts @@ -0,0 +1,16 @@ +import { sendS2SRequest } from 'app/api/request' + +type Params = { + id: string; +} + +export async function GET ( + request: Request, context: { params: Params }, +) { + const id = context.params.id + + return sendS2SRequest({ + method: 'GET', + uri: `/api/v1/logs/email/${id}`, + }) +} diff --git a/admin-panel/app/api/logs/email/route.ts b/admin-panel/app/api/logs/email/route.ts new file mode 100644 index 00000000..0b105c53 --- /dev/null +++ b/admin-panel/app/api/logs/email/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from 'next/server' +import { sendS2SRequest } from 'app/api/request' + +export async function GET (request: NextRequest) { + const pageSize = request.nextUrl.searchParams.get('page_size') + const pageNumber = request.nextUrl.searchParams.get('page_number') + + return sendS2SRequest({ + method: 'GET', + uri: `/api/v1/logs/email?page_size=${pageSize}&page_number=${pageNumber}`, + }) +} diff --git a/admin-panel/app/api/logs/sign-in/[id]/route.ts b/admin-panel/app/api/logs/sign-in/[id]/route.ts new file mode 100644 index 00000000..d4b24bab --- /dev/null +++ b/admin-panel/app/api/logs/sign-in/[id]/route.ts @@ -0,0 +1,16 @@ +import { sendS2SRequest } from 'app/api/request' + +type Params = { + id: string; +} + +export async function GET ( + request: Request, context: { params: Params }, +) { + const id = context.params.id + + return sendS2SRequest({ + method: 'GET', + uri: `/api/v1/logs/sign-in/${id}`, + }) +} diff --git a/admin-panel/app/api/logs/sign-in/route.ts b/admin-panel/app/api/logs/sign-in/route.ts new file mode 100644 index 00000000..61c03113 --- /dev/null +++ b/admin-panel/app/api/logs/sign-in/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from 'next/server' +import { sendS2SRequest } from 'app/api/request' + +export async function GET (request: NextRequest) { + const pageSize = request.nextUrl.searchParams.get('page_size') + const pageNumber = request.nextUrl.searchParams.get('page_number') + + return sendS2SRequest({ + method: 'GET', + uri: `/api/v1/logs/sign-in?page_size=${pageSize}&page_number=${pageNumber}`, + }) +} diff --git a/admin-panel/app/api/logs/sms/[id]/route.ts b/admin-panel/app/api/logs/sms/[id]/route.ts new file mode 100644 index 00000000..4d871d4e --- /dev/null +++ b/admin-panel/app/api/logs/sms/[id]/route.ts @@ -0,0 +1,16 @@ +import { sendS2SRequest } from 'app/api/request' + +type Params = { + id: string; +} + +export async function GET ( + request: Request, context: { params: Params }, +) { + const id = context.params.id + + return sendS2SRequest({ + method: 'GET', + uri: `/api/v1/logs/sms/${id}`, + }) +} diff --git a/admin-panel/app/api/logs/sms/route.ts b/admin-panel/app/api/logs/sms/route.ts new file mode 100644 index 00000000..24efa7ae --- /dev/null +++ b/admin-panel/app/api/logs/sms/route.ts @@ -0,0 +1,12 @@ +import { NextRequest } from 'next/server' +import { sendS2SRequest } from 'app/api/request' + +export async function GET (request: NextRequest) { + const pageSize = request.nextUrl.searchParams.get('page_size') + const pageNumber = request.nextUrl.searchParams.get('page_number') + + return sendS2SRequest({ + method: 'GET', + uri: `/api/v1/logs/sms?page_size=${pageSize}&page_number=${pageNumber}`, + }) +} diff --git a/admin-panel/components/ViewLink.tsx b/admin-panel/components/ViewLink.tsx new file mode 100644 index 00000000..8581b08e --- /dev/null +++ b/admin-panel/components/ViewLink.tsx @@ -0,0 +1,20 @@ +import { EyeIcon } from '@heroicons/react/16/solid' +import { Button } from 'flowbite-react' +import Link from 'next/link' + +const EditLink = ({ href }: { + href: string; +}) => { + return ( + + ) +} + +export default EditLink diff --git a/admin-panel/tools/route.ts b/admin-panel/tools/route.ts index 80ad54d1..99fd0673 100644 --- a/admin-panel/tools/route.ts +++ b/admin-panel/tools/route.ts @@ -4,4 +4,5 @@ export enum Internal { Roles = '/roles', Apps = '/apps', Scopes = '/scopes', + Logs = '/logs', } diff --git a/admin-panel/translations/en.json b/admin-panel/translations/en.json index 7c20d9f1..5d16737d 100644 --- a/admin-panel/translations/en.json +++ b/admin-panel/translations/en.json @@ -7,6 +7,7 @@ "roles": "Manage Roles", "apps": "Manage Apps", "scopes": "Manage Scopes", + "logs": "View Logs", "dashboard": "Dashboard" }, "common": { @@ -105,5 +106,18 @@ "new": "Create a scope", "locales": "Locales", "localeNote": "This will be displayed on the user app consent page." + }, + "logs": { + "receiver": "Receiver", + "success": "Success", + "time": "Time", + "emailLogs": "Email Logs", + "smsLogs": "SMS Logs", + "signInLogs": "Sign-in Logs", + "userId": "User ID", + "response": "Response", + "content": "Content", + "ip": "IP Address", + "detail": "detail" } } \ No newline at end of file diff --git a/admin-panel/translations/fr.json b/admin-panel/translations/fr.json index 5632c8ae..252693cd 100644 --- a/admin-panel/translations/fr.json +++ b/admin-panel/translations/fr.json @@ -7,6 +7,7 @@ "roles": "Gérer les rôles", "apps": "Gérer les applications", "scopes": "Gérer les portées", + "logs": "Afficher les journaux", "dashboard": "Tableau de bord" }, "common": { @@ -105,5 +106,18 @@ "new": "Créer une portée", "locales": "Langues", "localeNote": "Ceci sera affiché sur la page de consentement de l'application utilisateur." + }, + "logs": { + "receiver": "Destinataire", + "success": "Succès", + "time": "Heure", + "emailLogs": "Journaux des emails", + "smsLogs": "Journaux des SMS", + "signInLogs": "Journaux de connexion", + "userId": "ID utilisateur", + "response": "Réponse", + "content": "Contenu", + "ip": "Adresse IP", + "detail": "Détail" } } \ No newline at end of file diff --git a/server/migrations/pg/20240930220000_add_user_id_to_sign_in_log_table.cjs b/server/migrations/pg/20240930220000_add_user_id_to_sign_in_log_table.cjs new file mode 100644 index 00000000..b4b113b5 --- /dev/null +++ b/server/migrations/pg/20240930220000_add_user_id_to_sign_in_log_table.cjs @@ -0,0 +1,18 @@ +exports.up = async function (knex) { + return knex.schema.table( + 'sign_in_log', + function (table) { + table.integer('userId').notNullable() + .defaultTo(0) + }, + ) +} + +exports.down = async function (knex) { + return knex.schema.table( + 'sign_in_log', + function (table) { + table.dropColumn('userId') + }, + ) +} diff --git a/server/migrations/sqlite/0021_add_user_id_to_sign_in_log_table.sql b/server/migrations/sqlite/0021_add_user_id_to_sign_in_log_table.sql new file mode 100644 index 00000000..30dc065d --- /dev/null +++ b/server/migrations/sqlite/0021_add_user_id_to_sign_in_log_table.sql @@ -0,0 +1 @@ +ALTER TABLE sign_in_log ADD userId number DEFAULT 0; diff --git a/server/src/__tests__/normal/log.test.tsx b/server/src/__tests__/normal/log.test.tsx new file mode 100644 index 00000000..4fb27752 --- /dev/null +++ b/server/src/__tests__/normal/log.test.tsx @@ -0,0 +1,639 @@ +import { + afterEach, beforeEach, describe, expect, test, +} from 'vitest' +import { Database } from 'better-sqlite3' +import { Scope } from 'shared' +import app from 'index' +import { routeConfig } from 'configs' +import { + mockedKV, + migrate, + mock, +} from 'tests/mock' +import { + attachIndividualScopes, + dbTime, getS2sToken, +} from 'tests/util' +import { + emailLogModel, signInLogModel, smsLogModel, +} from 'models' + +let db: Database + +const insertEmailLogs = async () => { + await db.exec(` + INSERT INTO "email_log" + (receiver, success, response, content) + values ('test@email.com', 1, 'response 1', 'content 1') + `) + await db.exec(` + INSERT INTO "email_log" + (receiver, success, response, content) + values ('test@email.com', 1, 'response 2', 'content 2') + `) + await db.exec(` + INSERT INTO "email_log" + (receiver, success, response, content) + values ('test1@email.com', 1, 'response 3', 'content 3') + `) + await db.exec(` + INSERT INTO "email_log" + (receiver, success, response, content) + values ('test1@email.com', 0, 'response 4', 'content 4') + `) + await db.exec(` + INSERT INTO "email_log" + (receiver, success, response, content) + values ('test2@email.com', 1, 'response 5', 'content 5') + `) +} + +const emailLogs = [ + { + id: 1, + receiver: 'test@email.com', + success: true, + response: 'response 1', + content: 'content 1', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 2, + receiver: 'test@email.com', + success: true, + response: 'response 2', + content: 'content 2', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 3, + receiver: 'test1@email.com', + success: true, + response: 'response 3', + content: 'content 3', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 4, + receiver: 'test1@email.com', + success: false, + response: 'response 4', + content: 'content 4', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 5, + receiver: 'test2@email.com', + success: true, + response: 'response 5', + content: 'content 5', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, +] + +const insertSmsLogs = async () => { + await db.exec(` + INSERT INTO "sms_log" + (receiver, success, response, content) + values ('+6471231111', 1, 'response 1', 'content 1') + `) + await db.exec(` + INSERT INTO "sms_log" + (receiver, success, response, content) + values ('+6471231111', 1, 'response 2', 'content 2') + `) + await db.exec(` + INSERT INTO "sms_log" + (receiver, success, response, content) + values ('+6471231112', 1, 'response 3', 'content 3') + `) + await db.exec(` + INSERT INTO "sms_log" + (receiver, success, response, content) + values ('+6471231112', 0, 'response 4', 'content 4') + `) + await db.exec(` + INSERT INTO "sms_log" + (receiver, success, response, content) + values ('+6471231113', 1, 'response 5', 'content 5') + `) +} + +const smsLogs = [ + { + id: 1, + receiver: '+6471231111', + success: true, + response: 'response 1', + content: 'content 1', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 2, + receiver: '+6471231111', + success: true, + response: 'response 2', + content: 'content 2', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 3, + receiver: '+6471231112', + success: true, + response: 'response 3', + content: 'content 3', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 4, + receiver: '+6471231112', + success: false, + response: 'response 4', + content: 'content 4', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 5, + receiver: '+6471231113', + success: true, + response: 'response 5', + content: 'content 5', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, +] + +const insertSignInLogs = async () => { + await db.exec(` + INSERT INTO "sign_in_log" + ("userId", ip, detail) + values (1, '1-1-1-1', 'detail 1') + `) + await db.exec(` + INSERT INTO "sign_in_log" + ("userId", ip, detail) + values (1, '1-1-1-1', 'detail 2') + `) + await db.exec(` + INSERT INTO "sign_in_log" + ("userId", ip, detail) + values (1, '1-1-1-2', 'detail 3') + `) + await db.exec(` + INSERT INTO "sign_in_log" + ("userId", ip, detail) + values (2, '1-1-1-3', 'detail 4') + `) + await db.exec(` + INSERT INTO "sign_in_log" + ("userId", ip, detail) + values (3, '1-1-1-4', 'detail 5') + `) +} + +const signInLogs = [ + { + id: 1, + userId: 1, + ip: '1-1-1-1', + detail: 'detail 1', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 2, + userId: 1, + ip: '1-1-1-1', + detail: 'detail 2', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 3, + userId: 1, + ip: '1-1-1-2', + detail: 'detail 3', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 4, + userId: 2, + ip: '1-1-1-3', + detail: 'detail 4', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, + { + id: 5, + userId: 3, + ip: '1-1-1-4', + detail: 'detail 5', + createdAt: dbTime, + updatedAt: dbTime, + deletedAt: null, + }, +] + +beforeEach(async () => { + db = await migrate() +}) + +afterEach(async () => { + await mockedKV.empty() + await db.close() +}) + +const BaseRoute = routeConfig.InternalRoute.ApiLogs + +describe( + 'get all emailLogs', + () => { + test( + 'should return all emailLogs', + async () => { + await insertEmailLogs() + + const res = await app.request( + `${BaseRoute}/email`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json = await res.json() + expect(json).toStrictEqual({ + logs: emailLogs, + count: 5, + }) + }, + ) + + test( + 'could get email logs by pagination', + async () => { + await insertEmailLogs() + + const res = await app.request( + `${BaseRoute}/email?page_size=3&page_number=1`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json = await res.json() + expect(json).toStrictEqual({ + logs: [emailLogs[0], emailLogs[1], emailLogs[2]], + count: 5, + }) + + const res1 = await app.request( + `${BaseRoute}/email?page_size=2&page_number=2`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json1 = await res1.json() + expect(json1).toStrictEqual({ + logs: [emailLogs[2], emailLogs[3]], + count: 5, + }) + }, + ) + + test( + 'should not return log with non root scope', + async () => { + await insertEmailLogs() + await attachIndividualScopes(db) + + const res = await app.request( + `${BaseRoute}/email`, + { + headers: { + Authorization: `Bearer ${await getS2sToken( + db, + Scope.ReadUser, + )}`, + }, + }, + mock(db), + ) + expect(res.status).toBe(401) + }, + ) + }, +) + +describe( + 'get emailLog by id', + () => { + test( + 'should return emailLog by id 1', + async () => { + await insertEmailLogs() + + const res = await app.request( + `${BaseRoute}/email/1`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + + const json = await res.json() as { log: emailLogModel.Record } + expect(json.log).toStrictEqual(emailLogs[0]) + }, + ) + + test( + 'should return emailLog by id 2', + async () => { + await insertEmailLogs() + + const res = await app.request( + `${BaseRoute}/email/2`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json = await res.json() as { log: emailLogModel.Record } + expect(json.log).toStrictEqual(emailLogs[1]) + }, + ) + + test( + 'should return 404 when can not find email log by id', + async () => { + const res = await app.request( + `${BaseRoute}/email/6`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + + expect(res.status).toBe(404) + }, + ) + }, +) + +describe( + 'get all smsLogs', + () => { + test( + 'should return all smsLogs', + async () => { + await insertSmsLogs() + + const res = await app.request( + `${BaseRoute}/sms`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json = await res.json() + expect(json).toStrictEqual({ + logs: smsLogs, + count: 5, + }) + }, + ) + + test( + 'could get sms logs by pagination', + async () => { + await insertSmsLogs() + + const res = await app.request( + `${BaseRoute}/sms?page_size=3&page_number=1`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json = await res.json() + expect(json).toStrictEqual({ + logs: [smsLogs[0], smsLogs[1], smsLogs[2]], + count: 5, + }) + + const res1 = await app.request( + `${BaseRoute}/sms?page_size=2&page_number=2`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json1 = await res1.json() + expect(json1).toStrictEqual({ + logs: [smsLogs[2], smsLogs[3]], + count: 5, + }) + }, + ) + + test( + 'should not return log with non root scope', + async () => { + await insertSmsLogs() + await attachIndividualScopes(db) + + const res = await app.request( + `${BaseRoute}/sms`, + { + headers: { + Authorization: `Bearer ${await getS2sToken( + db, + Scope.ReadUser, + )}`, + }, + }, + mock(db), + ) + expect(res.status).toBe(401) + }, + ) + }, +) + +describe( + 'get smsLog by id', + () => { + test( + 'should return smsLog by id 1', + async () => { + await insertSmsLogs() + + const res = await app.request( + `${BaseRoute}/sms/1`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + + const json = await res.json() as { log: smsLogModel.Record } + expect(json.log).toStrictEqual(smsLogs[0]) + }, + ) + + test( + 'should return smsLog by id 2', + async () => { + await insertSmsLogs() + + const res = await app.request( + `${BaseRoute}/sms/2`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json = await res.json() as { log: smsLogModel.Record } + expect(json.log).toStrictEqual(smsLogs[1]) + }, + ) + + test( + 'should return 404 when can not find sms log by id', + async () => { + const res = await app.request( + `${BaseRoute}/sms/6`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + + expect(res.status).toBe(404) + }, + ) + }, +) + +describe( + 'get all signInLogs', + () => { + test( + 'should return all signInLogs', + async () => { + await insertSignInLogs() + + const res = await app.request( + `${BaseRoute}/sign-in`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json = await res.json() + expect(json).toStrictEqual({ + logs: signInLogs, + count: 5, + }) + }, + ) + + test( + 'could get sign-in logs by pagination', + async () => { + await insertSignInLogs() + + const res = await app.request( + `${BaseRoute}/sign-in?page_size=3&page_number=1`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json = await res.json() + expect(json).toStrictEqual({ + logs: [signInLogs[0], signInLogs[1], signInLogs[2]], + count: 5, + }) + + const res1 = await app.request( + `${BaseRoute}/sign-in?page_size=2&page_number=2`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json1 = await res1.json() + expect(json1).toStrictEqual({ + logs: [signInLogs[2], signInLogs[3]], + count: 5, + }) + }, + ) + + test( + 'should not return log with non root scope', + async () => { + await insertSignInLogs() + await attachIndividualScopes(db) + + const res = await app.request( + `${BaseRoute}/sign-in`, + { + headers: { + Authorization: `Bearer ${await getS2sToken( + db, + Scope.ReadUser, + )}`, + }, + }, + mock(db), + ) + expect(res.status).toBe(401) + }, + ) + }, +) + +describe( + 'get signInLog by id', + () => { + test( + 'should return signInLog by id 1', + async () => { + await insertSignInLogs() + + const res = await app.request( + `${BaseRoute}/sign-in/1`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + + const json = await res.json() as { log: signInLogModel.Record } + expect(json.log).toStrictEqual(signInLogs[0]) + }, + ) + + test( + 'should return signInLog by id 2', + async () => { + await insertSignInLogs() + + const res = await app.request( + `${BaseRoute}/sign-in/2`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + const json = await res.json() as { log: signInLogModel.Record } + expect(json.log).toStrictEqual(signInLogs[1]) + }, + ) + + test( + 'should return 404 when can not find sign-in log by id', + async () => { + const res = await app.request( + `${BaseRoute}/sign-in/6`, + { headers: { Authorization: `Bearer ${await getS2sToken(db)}` } }, + mock(db), + ) + + expect(res.status).toBe(404) + }, + ) + }, +) diff --git a/server/src/configs/route.ts b/server/src/configs/route.ts index ae44a47f..ab80a09d 100644 --- a/server/src/configs/route.ts +++ b/server/src/configs/route.ts @@ -5,6 +5,7 @@ export enum InternalRoute { ApiApps = '/api/v1/apps', ApiRoles = '/api/v1/roles', ApiScopes = '/api/v1/scopes', + ApiLogs = '/api/v1/logs', } export enum OauthRoute { diff --git a/server/src/handlers/index.ts b/server/src/handlers/index.ts index 66bd8fa5..e510a63b 100644 --- a/server/src/handlers/index.ts +++ b/server/src/handlers/index.ts @@ -4,4 +4,5 @@ export * as userHandler from 'handlers/user' export * as appHandler from 'handlers/app' export * as roleHandler from 'handlers/role' export * as scopeHandler from 'handlers/scope' +export * as logHandler from 'handlers/log' export * as identityHandler from 'handlers/identity' diff --git a/server/src/handlers/log.ts b/server/src/handlers/log.ts new file mode 100644 index 00000000..7cf1e788 --- /dev/null +++ b/server/src/handlers/log.ts @@ -0,0 +1,91 @@ +import { Context } from 'hono' +import { typeConfig } from 'configs' +import { logService } from 'services' +import { PaginationDto } from 'dtos/common' + +export const getEmailLogs = async (c: Context) => { + const { + page_size: pageSize, + page_number: pageNumber, + } = c.req.query() + const pagination = pageSize && pageNumber + ? new PaginationDto({ + pageSize: Number(pageSize), + pageNumber: Number(pageNumber), + }) + : undefined + + const res = await logService.getEmailLogs( + c, + pagination, + ) + + return c.json(res) +} + +export const getEmailLog = async (c: Context) => { + const id = Number(c.req.param('id')) + const log = await logService.getEmailLogById( + c, + id, + ) + return c.json({ log }) +} + +export const getSmsLogs = async (c: Context) => { + const { + page_size: pageSize, + page_number: pageNumber, + } = c.req.query() + const pagination = pageSize && pageNumber + ? new PaginationDto({ + pageSize: Number(pageSize), + pageNumber: Number(pageNumber), + }) + : undefined + + const res = await logService.getSmsLogs( + c, + pagination, + ) + + return c.json(res) +} + +export const getSmsLog = async (c: Context) => { + const id = Number(c.req.param('id')) + const log = await logService.getSmsLogById( + c, + id, + ) + return c.json({ log }) +} + +export const getSignInLogs = async (c: Context) => { + const { + page_size: pageSize, + page_number: pageNumber, + } = c.req.query() + const pagination = pageSize && pageNumber + ? new PaginationDto({ + pageSize: Number(pageSize), + pageNumber: Number(pageNumber), + }) + : undefined + + const res = await logService.getSignInLogs( + c, + pagination, + ) + + return c.json(res) +} + +export const getSignInLog = async (c: Context) => { + const id = Number(c.req.param('id')) + const log = await logService.getSignInLogById( + c, + id, + ) + return c.json({ log }) +} diff --git a/server/src/handlers/oauth.ts b/server/src/handlers/oauth.ts index f0629e98..5d21f57b 100644 --- a/server/src/handlers/oauth.ts +++ b/server/src/handlers/oauth.ts @@ -274,7 +274,9 @@ export const postTokenAuthCode = async (c: Context) => { await signInLogModel.create( c.env.DB, { - ip: ip ?? null, detail, + userId: authInfo.user.id, + ip: ip ?? null, + detail, }, ) } diff --git a/server/src/middlewares/auth.ts b/server/src/middlewares/auth.ts index ff92fb9c..d7a39f46 100644 --- a/server/src/middlewares/auth.ts +++ b/server/src/middlewares/auth.ts @@ -84,6 +84,18 @@ const s2sScopeGuard = async ( return true } +export const s2sRoot = bearerAuth({ + verifyToken: async ( + token, c: Context, + ) => { + return s2sScopeGuard( + c, + token, + Scope.Root, + ) + }, +}) + export const s2sReadUser = bearerAuth({ verifyToken: async ( token, c: Context, diff --git a/server/src/models/emailLog.ts b/server/src/models/emailLog.ts index 014c407f..9c6c94a4 100644 --- a/server/src/models/emailLog.ts +++ b/server/src/models/emailLog.ts @@ -1,8 +1,32 @@ import { adapterConfig, errorConfig, + typeConfig, } from 'configs' import { dbUtil } from 'utils' +export interface Common { + id: number; + receiver: string; + response: string; + content: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface Raw extends Common { + success: number; +} + +export interface Record extends Common { + success: boolean; +} + +export interface PaginatedRecords { + logs: Record[]; + count: number; +} + export interface Create { success: number; receiver: string; @@ -12,6 +36,46 @@ export interface Create { const TableName = adapterConfig.TableName.EmailLog +export const convertToRecord = (raw: Raw): Record => ({ + ...raw, + success: !!raw.success, +}) + +export const getAll = async ( + db: D1Database, + option?: { + search?: typeConfig.Search; + pagination?: typeConfig.Pagination; + }, +): Promise => { + const stmt = dbUtil.d1SelectAllQuery( + db, + TableName, + option, + ) + const { results: logs }: { results: Raw[] } = await stmt.all() + return logs.map((raw) => convertToRecord(raw)) +} + +export const count = async (db: D1Database): Promise => { + const query = `SELECT COUNT(*) as count FROM ${TableName} where "deletedAt" IS NULL` + const stmt = db.prepare(query) + const result = await stmt.first() as { count: number } + return Number(result.count) +} + +export const getById = async ( + db: D1Database, + id: number, +): Promise => { + const query = `SELECT * FROM ${TableName} WHERE id = $1 AND "deletedAt" IS NULL` + + const stmt = db.prepare(query) + .bind(id) + const log = await stmt.first() as Raw | null + return log ? convertToRecord(log) : log +} + export const create = async ( db: D1Database, create: Create, ): Promise => { diff --git a/server/src/models/signInLog.ts b/server/src/models/signInLog.ts index 6ddaf5b6..24f3c863 100644 --- a/server/src/models/signInLog.ts +++ b/server/src/models/signInLog.ts @@ -1,20 +1,73 @@ import { adapterConfig, errorConfig, + typeConfig, } from 'configs' import { dbUtil } from 'utils' +export interface Record { + id: number; + userId: number; + ip: string | null; + detail: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface PaginatedRecords { + logs: Record[]; + count: number; +} + export interface Create { + userId: number; ip: string | null; detail: string | null; } const TableName = adapterConfig.TableName.SignInLog +export const getAll = async ( + db: D1Database, + option?: { + search?: typeConfig.Search; + pagination?: typeConfig.Pagination; + }, +): Promise => { + const stmt = dbUtil.d1SelectAllQuery( + db, + TableName, + option, + ) + const { results: logs }: { results: Record[] } = await stmt.all() + return logs +} + +export const count = async (db: D1Database): Promise => { + const query = `SELECT COUNT(*) as count FROM ${TableName} where "deletedAt" IS NULL` + const stmt = db.prepare(query) + const result = await stmt.first() as { count: number } + return Number(result.count) +} + +export const getById = async ( + db: D1Database, + id: number, +): Promise => { + const query = `SELECT * FROM ${TableName} WHERE id = $1 AND "deletedAt" IS NULL` + + const stmt = db.prepare(query) + .bind(id) + const log = await stmt.first() as Record | null + return log +} + export const create = async ( db: D1Database, create: Create, ): Promise => { - const query = `INSERT INTO ${TableName} (ip, detail) values ($1, $2)` + const query = `INSERT INTO ${TableName} ("userId", ip, detail) values ($1, $2, $3)` const stmt = db.prepare(query).bind( + create.userId, create.ip, create.detail, ) diff --git a/server/src/models/smsLog.ts b/server/src/models/smsLog.ts index 0c5724fa..ccd369fe 100644 --- a/server/src/models/smsLog.ts +++ b/server/src/models/smsLog.ts @@ -1,8 +1,32 @@ import { adapterConfig, errorConfig, + typeConfig, } from 'configs' import { dbUtil } from 'utils' +export interface Common { + id: number; + receiver: string; + response: string; + content: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface Raw extends Common { + success: number; +} + +export interface Record extends Common { + success: boolean; +} + +export interface PaginatedRecords { + logs: Record[]; + count: number; +} + export interface Create { success: number; receiver: string; @@ -12,6 +36,46 @@ export interface Create { const TableName = adapterConfig.TableName.SmsLog +export const convertToRecord = (raw: Raw): Record => ({ + ...raw, + success: !!raw.success, +}) + +export const getAll = async ( + db: D1Database, + option?: { + search?: typeConfig.Search; + pagination?: typeConfig.Pagination; + }, +): Promise => { + const stmt = dbUtil.d1SelectAllQuery( + db, + TableName, + option, + ) + const { results: logs }: { results: Raw[] } = await stmt.all() + return logs.map((raw) => convertToRecord(raw)) +} + +export const count = async (db: D1Database): Promise => { + const query = `SELECT COUNT(*) as count FROM ${TableName} where "deletedAt" IS NULL` + const stmt = db.prepare(query) + const result = await stmt.first() as { count: number } + return Number(result.count) +} + +export const getById = async ( + db: D1Database, + id: number, +): Promise => { + const query = `SELECT * FROM ${TableName} WHERE id = $1 AND "deletedAt" IS NULL` + + const stmt = db.prepare(query) + .bind(id) + const log = await stmt.first() as Raw | null + return log ? convertToRecord(log) : null +} + export const create = async ( db: D1Database, create: Create, ): Promise => { diff --git a/server/src/router.tsx b/server/src/router.tsx index 221aff40..eab13d52 100644 --- a/server/src/router.tsx +++ b/server/src/router.tsx @@ -2,7 +2,7 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' import { oauthRoute, userRoute, identityRoute, - otherRoute, appRoute, roleRoute, scopeRoute, + otherRoute, appRoute, roleRoute, scopeRoute, logRoute, } from 'routes' import { setupMiddleware } from 'middlewares' import { typeConfig } from 'configs' @@ -30,6 +30,10 @@ export const loadRouters = (app: Hono) => { '/', userRoute, ) + app.route( + '/', + logRoute, + ) app.route( '/', identityRoute, diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index ed533451..1798164d 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -3,5 +3,6 @@ export { default as userRoute } from 'routes/user' export { default as appRoute } from 'routes/app' export { default as roleRoute } from 'routes/role' export { default as scopeRoute } from 'routes/scope' +export { default as logRoute } from 'routes/log' export { default as identityRoute } from 'routes/identity' export { default as otherRoute } from 'routes/other' diff --git a/server/src/routes/log.tsx b/server/src/routes/log.tsx new file mode 100644 index 00000000..f4dc733c --- /dev/null +++ b/server/src/routes/log.tsx @@ -0,0 +1,223 @@ +import { Hono } from 'hono' +import { + routeConfig, typeConfig, +} from 'configs' +import { authMiddleware } from 'middlewares' +import { logHandler } from 'handlers' + +const BaseRoute = routeConfig.InternalRoute.ApiLogs +const logRoutes = new Hono() +export default logRoutes + +/** + * @swagger + * /api/v1/logs/email: + * get: + * summary: Get a list of email logs + * description: Required scope - root + * tags: [Logs] + * parameters: + * - in: query + * name: page_size + * schema: + * type: integer + * description: Number of logs to return per page + * - in: query + * name: page_number + * schema: + * type: integer + * description: Page number to return + * responses: + * 200: + * description: A list of email logs + * content: + * application/json: + * schema: + * type: object + * properties: + * logs: + * type: array + * items: + * $ref: '#/components/schemas/EmailLog' + * count: + * type: integer + * description: Total number of logs matching the query + */ +logRoutes.get( + `${BaseRoute}/email`, + authMiddleware.s2sRoot, + logHandler.getEmailLogs, +) + +/** + * @swagger + * /api/v1/logs/email/{id}: + * get: + * summary: Get an email log by id + * description: Required scope - root + * tags: [Logs] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: number + * description: The unique ID of the email log + * responses: + * 200: + * description: A single email log object + * content: + * application/json: + * schema: + * type: object + * properties: + * log: + * $ref: '#/components/schemas/EmailLog' + */ +logRoutes.get( + `${BaseRoute}/email/:id`, + authMiddleware.s2sRoot, + logHandler.getEmailLog, +) + +/** + * @swagger + * /api/v1/logs/sms: + * get: + * summary: Get a list of SMS logs + * description: Required scope - root + * tags: [Logs] + * parameters: + * - in: query + * name: page_size + * schema: + * type: integer + * description: Number of logs to return per page + * - in: query + * name: page_number + * schema: + * type: integer + * description: Page number to return + * responses: + * 200: + * description: A list of SMS logs + * content: + * application/json: + * schema: + * type: object + * properties: + * logs: + * type: array + * items: + * $ref: '#/components/schemas/SmsLog' + * count: + * type: integer + * description: Total number of logs matching the query + */ +logRoutes.get( + `${BaseRoute}/sms`, + authMiddleware.s2sRoot, + logHandler.getSmsLogs, +) + +/** + * @swagger + * /api/v1/logs/sms/{id}: + * get: + * summary: Get an SMS log by id + * description: Required scope - root + * tags: [Logs] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: number + * description: The unique ID of the SMS log + * responses: + * 200: + * description: A single SMS log object + * content: + * application/json: + * schema: + * type: object + * properties: + * log: + * $ref: '#/components/schemas/SmsLog' + */ +logRoutes.get( + `${BaseRoute}/sms/:id`, + authMiddleware.s2sRoot, + logHandler.getSmsLog, +) + +/** + * @swagger + * /api/v1/logs/sign-in: + * get: + * summary: Get a list of Sign-in logs + * description: Required scope - root + * tags: [Logs] + * parameters: + * - in: query + * name: page_size + * schema: + * type: integer + * description: Number of logs to return per page + * - in: query + * name: page_number + * schema: + * type: integer + * description: Page number to return + * responses: + * 200: + * description: A list of Sign-in logs + * content: + * application/json: + * schema: + * type: object + * properties: + * logs: + * type: array + * items: + * $ref: '#/components/schemas/SignInLog' + * count: + * type: integer + * description: Total number of logs matching the query + */ +logRoutes.get( + `${BaseRoute}/sign-in`, + authMiddleware.s2sRoot, + logHandler.getSignInLogs, +) + +/** + * @swagger + * /api/v1/logs/sign-in/{id}: + * get: + * summary: Get an sign-in log by id + * description: Required scope - root + * tags: [Logs] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: number + * description: The unique ID of the sign-in log + * responses: + * 200: + * description: A single sign-in log object + * content: + * application/json: + * schema: + * type: object + * properties: + * log: + * $ref: '#/components/schemas/SignInLog' + */ +logRoutes.get( + `${BaseRoute}/sign-in/:id`, + authMiddleware.s2sRoot, + logHandler.getSignInLog, +) diff --git a/server/src/scripts/generate-swagger.cjs b/server/src/scripts/generate-swagger.cjs index 229c0b18..18327177 100644 --- a/server/src/scripts/generate-swagger.cjs +++ b/server/src/scripts/generate-swagger.cjs @@ -13,6 +13,9 @@ const { User, UserDetail, PutUserReq, UserConsentedApp, } = require('./schemas/user.cjs') +const { + EmailLog, SmsLog, SignInLog, +} = require('./schemas/log.cjs') const options = { definition: { @@ -29,6 +32,7 @@ const options = { clientCredentials: { tokenUrl: '/oauth2/v1/token', scopes: { + root: 'Full access', read_app: 'Read access to app', write_app: 'Write access to app', read_user: 'Read access to user', @@ -58,6 +62,9 @@ const options = { UserDetail, UserConsentedApp, PutUserReq, + EmailLog, + SmsLog, + SignInLog, }, }, }, @@ -66,6 +73,7 @@ const options = { './src/routes/role.tsx', './src/routes/app.tsx', './src/routes/user.tsx', + './src/routes/log.tsx', ], } diff --git a/server/src/scripts/schemas/log.cjs b/server/src/scripts/schemas/log.cjs new file mode 100644 index 00000000..38de185c --- /dev/null +++ b/server/src/scripts/schemas/log.cjs @@ -0,0 +1,55 @@ +const EmailLog = { + type: 'object', + properties: { + id: { type: 'number' }, + receiver: { type: 'string' }, + response: { type: 'string' }, + content: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + deletedAt: { + type: 'string', + nullable: true, + }, + }, +} + +const SmsLog = { + type: 'object', + properties: { + id: { type: 'number' }, + receiver: { type: 'string' }, + response: { type: 'string' }, + content: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + deletedAt: { + type: 'string', + nullable: true, + }, + }, +} + +const SignInLog = { + type: 'object', + properties: { + id: { type: 'number' }, + userId: { type: 'number' }, + ip: { + type: 'string', nullable: true, + }, + detail: { + type: 'string', nullable: true, + }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + deletedAt: { + type: 'string', + nullable: true, + }, + }, +} + +module.exports = { + EmailLog, SmsLog, SignInLog, +} diff --git a/server/src/scripts/swagger.json b/server/src/scripts/swagger.json index 0319cd7e..17a1c4fe 100644 --- a/server/src/scripts/swagger.json +++ b/server/src/scripts/swagger.json @@ -12,6 +12,7 @@ "clientCredentials": { "tokenUrl": "/oauth2/v1/token", "scopes": { + "root": "Full access", "read_app": "Read access to app", "write_app": "Write access to app", "read_user": "Read access to user", @@ -454,6 +455,89 @@ } } } + }, + "EmailLog": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "receiver": { + "type": "string" + }, + "response": { + "type": "string" + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": "string", + "nullable": true + } + } + }, + "SmsLog": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "receiver": { + "type": "string" + }, + "response": { + "type": "string" + }, + "content": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": "string", + "nullable": true + } + } + }, + "SignInLog": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "userId": { + "type": "number" + }, + "ip": { + "type": "string", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "type": "string", + "nullable": true + } + } } } }, @@ -1437,6 +1521,270 @@ } } } + }, + "/api/v1/logs/email": { + "get": { + "summary": "Get a list of email logs", + "description": "Required scope - root", + "tags": [ + "Logs" + ], + "parameters": [ + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + }, + "description": "Number of logs to return per page" + }, + { + "in": "query", + "name": "page_number", + "schema": { + "type": "integer" + }, + "description": "Page number to return" + } + ], + "responses": { + "200": { + "description": "A list of email logs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EmailLog" + } + }, + "count": { + "type": "integer", + "description": "Total number of logs matching the query" + } + } + } + } + } + } + } + } + }, + "/api/v1/logs/email/{id}": { + "get": { + "summary": "Get an email log by id", + "description": "Required scope - root", + "tags": [ + "Logs" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "number" + }, + "description": "The unique ID of the email log" + } + ], + "responses": { + "200": { + "description": "A single email log object", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "log": { + "$ref": "#/components/schemas/EmailLog" + } + } + } + } + } + } + } + } + }, + "/api/v1/logs/sms": { + "get": { + "summary": "Get a list of SMS logs", + "description": "Required scope - root", + "tags": [ + "Logs" + ], + "parameters": [ + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + }, + "description": "Number of logs to return per page" + }, + { + "in": "query", + "name": "page_number", + "schema": { + "type": "integer" + }, + "description": "Page number to return" + } + ], + "responses": { + "200": { + "description": "A list of SMS logs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SmsLog" + } + }, + "count": { + "type": "integer", + "description": "Total number of logs matching the query" + } + } + } + } + } + } + } + } + }, + "/api/v1/logs/sms/{id}": { + "get": { + "summary": "Get an SMS log by id", + "description": "Required scope - root", + "tags": [ + "Logs" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "number" + }, + "description": "The unique ID of the SMS log" + } + ], + "responses": { + "200": { + "description": "A single SMS log object", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "log": { + "$ref": "#/components/schemas/SmsLog" + } + } + } + } + } + } + } + } + }, + "/api/v1/logs/sign-in": { + "get": { + "summary": "Get a list of Sign-in logs", + "description": "Required scope - root", + "tags": [ + "Logs" + ], + "parameters": [ + { + "in": "query", + "name": "page_size", + "schema": { + "type": "integer" + }, + "description": "Number of logs to return per page" + }, + { + "in": "query", + "name": "page_number", + "schema": { + "type": "integer" + }, + "description": "Page number to return" + } + ], + "responses": { + "200": { + "description": "A list of Sign-in logs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SignInLog" + } + }, + "count": { + "type": "integer", + "description": "Total number of logs matching the query" + } + } + } + } + } + } + } + } + }, + "/api/v1/logs/sign-in/{id}": { + "get": { + "summary": "Get an sign-in log by id", + "description": "Required scope - root", + "tags": [ + "Logs" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "number" + }, + "description": "The unique ID of the sign-in log" + } + ], + "responses": { + "200": { + "description": "A single sign-in log object", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "log": { + "$ref": "#/components/schemas/SignInLog" + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 3feb7c10..0d52f9c1 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -8,3 +8,4 @@ export * as emailService from 'services/email' export * as smsService from 'services/sms' export * as roleService from 'services/role' export * as scopeService from 'services/scope' +export * as logService from 'services/log' diff --git a/server/src/services/log.ts b/server/src/services/log.ts new file mode 100644 index 00000000..bd3d7c5a --- /dev/null +++ b/server/src/services/log.ts @@ -0,0 +1,100 @@ +import { Context } from 'hono' +import { + errorConfig, typeConfig, +} from 'configs' +import { + emailLogModel, signInLogModel, smsLogModel, +} from 'models' + +export const getEmailLogs = async ( + c: Context, + pagination: typeConfig.Pagination | undefined, +): Promise => { + const logs = await emailLogModel.getAll( + c.env.DB, + { pagination }, + ) + const count = pagination + ? await emailLogModel.count(c.env.DB) + : logs.length + + return { + logs, count, + } +} + +export const getEmailLogById = async ( + c: Context, + id: number, +): Promise => { + const log = await emailLogModel.getById( + c.env.DB, + id, + ) + + if (!log) throw new errorConfig.NotFound() + + return log +} + +export const getSmsLogs = async ( + c: Context, + pagination: typeConfig.Pagination | undefined, +): Promise => { + const logs = await smsLogModel.getAll( + c.env.DB, + { pagination }, + ) + const count = pagination + ? await smsLogModel.count(c.env.DB) + : logs.length + + return { + logs, count, + } +} + +export const getSmsLogById = async ( + c: Context, + id: number, +): Promise => { + const log = await smsLogModel.getById( + c.env.DB, + id, + ) + + if (!log) throw new errorConfig.NotFound() + + return log +} + +export const getSignInLogs = async ( + c: Context, + pagination: typeConfig.Pagination | undefined, +): Promise => { + const logs = await signInLogModel.getAll( + c.env.DB, + { pagination }, + ) + const count = pagination + ? await signInLogModel.count(c.env.DB) + : logs.length + + return { + logs, count, + } +} + +export const getSignInLogById = async ( + c: Context, + id: number, +): Promise => { + const log = await signInLogModel.getById( + c.env.DB, + id, + ) + + if (!log) throw new errorConfig.NotFound() + + return log +} diff --git a/server/src/tests/mock.ts b/server/src/tests/mock.ts index d78a28af..f386c5fc 100644 --- a/server/src/tests/mock.ts +++ b/server/src/tests/mock.ts @@ -175,6 +175,24 @@ export const migrate = async () => { name: file, }) } + const getRows = ( + result: any, query: string, + ) => { + let rows = result.rows + if (query.includes(' "user" ')) { + rows = result.rows.map((row: userModel.Raw) => formatUser(row)) + } + return rows + } + const getRow = ( + record: any, query: string, + ) => { + let row = record + if (query.includes(' "user" ')) { + row = formatUser(record) + } + return row + } return { raw: async ( query: string, params?: string[], @@ -185,7 +203,10 @@ export const migrate = async () => { ) const formatted = { ...result, - rows: query.includes(' "user" ') ? result.rows.map((row: userModel.Raw) => formatUser(row)) : result.rows, + rows: getRows( + result, + query, + ), } return formatted }, @@ -202,15 +223,21 @@ export const migrate = async () => { params, ) const record = res?.rows[0] - return query.includes(' "user" ') ? formatUser(record) : record + return getRow( + record, + query, + ) }, all: async (...params: string[]) => { const res = await db.raw( query, params, ) - const records = res?.rows - return query.includes(' "user" ') ? records.map((record: userModel.Raw) => formatUser(record)) : records + const records = getRows( + res, + query, + ) + return records }, }), exec: async (query: string) => db.raw(query),