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),