feat: add ESLint, Prettier, Husky, and lint-staged setup

This commit is contained in:
copilot-swe-agent[bot]
2025-10-18 23:06:56 +00:00
parent 4673ec63b2
commit 06dabfbe81
31 changed files with 7366 additions and 102 deletions

24
.commitlintrc.json Normal file
View File

@@ -0,0 +1,24 @@
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert"
]
],
"subject-case": [2, "never", ["upper-case", "pascal-case"]],
"header-max-length": [2, "always", 100]
}
}

25
.eslintignore Normal file
View File

@@ -0,0 +1,25 @@
# Dependencies
node_modules/
**/node_modules/
# Build outputs
dist/
build/
out/
.next/
.nuxt/
**/.vitepress/dist/
# Generated files
coverage/
*.tsbuildinfo
.vscode-test
.eslintcache
# Prisma generated
**/src/generated/prisma/
# Config files that don't need linting
*.config.js
*.config.ts
.prettierrc.js

1
.husky/commit-msg Executable file
View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit $1

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

1
.husky/pre-push Executable file
View File

@@ -0,0 +1 @@
npm run type-check

35
.prettierignore Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules/
**/node_modules/
# Build outputs
dist/
build/
out/
.next/
.nuxt/
**/.vitepress/dist/
# Generated files
coverage/
*.tsbuildinfo
.vscode-test
.eslintcache
.stylelintcache
# Prisma generated
**/src/generated/prisma/
# Logs
*.log
logs/
# Lock files
package-lock.json
yarn.lock
pnpm-lock.yaml
# Environment files
.env
.env.*
!.env.example

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 4,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf"
}

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"prisma.prisma"
]
}

38
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,38 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.workingDirectories": ["./backend", "./frontend"],
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

67
backend/eslint.config.mjs Normal file
View File

@@ -0,0 +1,67 @@
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import security from 'eslint-plugin-security';
import importPlugin from 'eslint-plugin-import';
export default tseslint.config(
{
ignores: [
'node_modules',
'dist',
'build',
'src/generated',
'eslint.config.mjs',
],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
files: ['**/*.ts'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
'@typescript-eslint': tseslint.plugin,
security: security,
import: importPlugin,
},
rules: {
...security.configs.recommended.rules,
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'no-console': 'warn',
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'import/newline-after-import': 'error',
'import/no-duplicates': 'error',
},
}
);

3094
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,12 @@
"main": "src/index.ts",
"scripts": {
"dev": "nodemon --watch src --exec ts-node src/index.ts",
"dev:api": "nodemon --watch src --exec ts-node --files src/server.ts"
"dev:api": "nodemon --watch src --exec ts-node --files src/server.ts",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"type-check": "tsc --noEmit"
},
"keywords": [],
"author": "",
@@ -27,6 +32,7 @@
"winston": "^3.17.0"
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.2",
@@ -35,8 +41,16 @@
"@types/morgan": "^1.9.9",
"@types/node": "^22.15.21",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-security": "^3.0.1",
"nodemon": "^3.1.10",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"typescript-eslint": "^8.46.1"
}
}

View File

@@ -12,7 +12,11 @@ declare global {
const ADMIN_DISCORD_IDS = env.ADMIN_DISCORD_IDS;
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
export function requireAuth(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {

View File

@@ -50,4 +50,4 @@ export type SuspicionEntry = {
oldClients?: string[];
newClients?: string[];
};
};

View File

@@ -89,6 +89,10 @@
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
"noUnusedLocals": true /* Enable error reporting when local variables aren't read. */,
"noUnusedParameters": true /* Raise an error when a function parameter isn't read. */,
"noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */,
"noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */

View File

@@ -3,26 +3,63 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import jsxA11y from 'eslint-plugin-jsx-a11y'
// @ts-ignore - import plugin doesn't have types
import importPlugin from 'eslint-plugin-import'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
{ ignores: ['dist', 'node_modules'] },
{
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
jsxA11y.flatConfigs.recommended,
],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
import: importPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'no-console': 'warn',
'import/order': [
'error',
{
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'import/newline-after-import': 'error',
'import/no-duplicates': 'error',
},
}
)

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,10 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"type-check": "tsc -b --noEmit",
"preview": "vite preview"
},
"dependencies": {
@@ -26,10 +30,14 @@
"@vitejs/plugin-react-swc": "^3.9.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import RequireAdmin from './components/RequireAdmin';
import RequireAuth from './components/RequireAuth';
import { AuthCallback, Bans, Dashboard, Login, Suspicion } from './pages';
@@ -16,37 +17,19 @@ function App() {
return (
<BrowserRouter>
<Routes>
<Route
path='/login'
element={<Login />}
/>
<Route
path='/auth/callback'
element={<AuthCallback />}
/>
<Route path="/login" element={<Login />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route element={<RequireAuth />}>
<Route
path='/'
element={<Dashboard />}
/>
<Route
path='/suspicion'
element={<Suspicion />}
/>
<Route path="/" element={<Dashboard />} />
<Route path="/suspicion" element={<Suspicion />} />
<Route element={<RequireAdmin />}>
<Route
path='/bans'
element={<Bans />}
/>
<Route path="/bans" element={<Bans />} />
</Route>
</Route>
<Route
path='*'
element={<Navigate to='/' />}
/>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
);

View File

@@ -1,5 +1,6 @@
import toast from 'react-hot-toast';
import { Navigate, Outlet } from 'react-router-dom';
import { useSession } from '../hooks/useSession';
export default function RequireAdmin() {
@@ -8,12 +9,7 @@ export default function RequireAdmin() {
if (loading) return <p>Loading...</p>;
if (!session || session.role !== 'ADMIN') {
toast.error('You do not have permission to view this page');
return (
<Navigate
to='/'
replace
/>
);
return <Navigate to="/" replace />;
}
return <Outlet />;
}

View File

@@ -1,16 +1,10 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useSession } from '../hooks/useSession';
export default function RequireAuth() {
const { session, loading } = useSession();
if (loading) return null; // or <LoadingScreen />
return session ? (
<Outlet />
) : (
<Navigate
to='/login'
replace
/>
);
return session ? <Outlet /> : <Navigate to="/login" replace />;
}

View File

@@ -1,3 +1,5 @@
import toast from 'react-hot-toast';
import { useSession } from '../hooks/useSession';
import api from '../lib/api';
import { useAuth } from '../store/auth';
@@ -18,26 +20,26 @@ export default function SessionStatus() {
};
if (loading) return <p>Loading session...</p>;
if (error) return <p className='text-red-500'>Error: {error}</p>;
if (error) return <p className="text-red-500">Error: {error}</p>;
if (!session) return <p>Not logged in</p>;
return (
<div className='flex items-center gap-4 p-4 border rounded bg-gray-900 text-white'>
<div className="flex items-center gap-4 p-4 border rounded bg-gray-900 text-white">
<img
src={`https://cdn.discordapp.com/avatars/${session.discordId}/${session.avatar}.png`}
alt='Avatar'
className='w-10 h-10 rounded-full'
alt="Avatar"
className="w-10 h-10 rounded-full"
/>
<div className='flex-1'>
<p className='font-bold'>{session.username}</p>
<p className='text-sm text-gray-400'>{session.email}</p>
<p className='text-xs mt-1'>
Role: <span className='font-mono'>{session.role}</span>
<div className="flex-1">
<p className="font-bold">{session.username}</p>
<p className="text-sm text-gray-400">{session.email}</p>
<p className="text-xs mt-1">
Role: <span className="font-mono">{session.role}</span>
</p>
</div>
<button
onClick={handleLogout}
className='px-3 py-1 text-sm bg-red-600 rounded hover:bg-red-700'
className="px-3 py-1 text-sm bg-red-600 rounded hover:bg-red-700"
>
Logout
</button>

View File

@@ -1,6 +1,15 @@
import { useEffect, useState } from 'react';
import api from '../lib/api';
type Guild = {
id: string;
name: string;
icon?: string;
owner?: boolean;
permissions?: string;
};
type Session = {
discordId: string;
username: string;
@@ -9,7 +18,7 @@ type Session = {
locale?: string;
verified?: boolean;
role: 'ADMIN' | 'USER' | 'MODERATOR' | 'BANNED';
guilds?: any[];
guilds?: Guild[];
};
export function useSession() {

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { useAuth } from '../store/auth';
const api = axios.create({

View File

@@ -1,5 +1,6 @@
import { createRoot } from 'react-dom/client';
import { Toaster } from 'react-hot-toast';
import App from './App.tsx';
import './index.css';
@@ -7,7 +8,7 @@ createRoot(document.getElementById('root')!).render(
<>
<App />
<Toaster
position='top-right'
position="top-right"
toastOptions={{
style: {
background: '#1e1e2e', // ctp.base

View File

@@ -1,8 +1,9 @@
import { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';
import api from '../lib/api';
import { useAuth } from '../store/auth';
import toast from 'react-hot-toast';
export default function AuthCallback() {
const navigate = useNavigate();
@@ -31,5 +32,5 @@ export default function AuthCallback() {
});
}, [navigate, setToken]);
return <p className='text-center mt-20'>Logging in with Discord...</p>;
return <p className="text-center mt-20">Logging in with Discord...</p>;
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../lib/api';
type UserEntry = {
@@ -14,16 +14,21 @@ type UserEntry = {
fastReactionCount?: number;
};
type BannedUser = {
userId: string;
};
function Dashboard() {
const [data, setData] = useState<UserEntry[]>([]);
const [bannedUsers, setBannedUsers] = useState<Set<string>>(new Set());
const [filterBanned, setFilterBanned] = useState(false);
const navigate = useNavigate();
useEffect(() => {
api.get('/suspicion').then((res) => setData(res.data));
api.get('/userbans').then((res) => {
const ids = new Set(res.data.map((u: any) => u.userId));
const ids = new Set<string>(
res.data.map((u: BannedUser) => u.userId)
);
setBannedUsers(ids);
});
}, []);
@@ -33,21 +38,21 @@ function Dashboard() {
: data;
return (
<div className='p-6'>
<h1 className='text-2xl font-bold mb-4'>Suspicion Dashboard</h1>
<label className='flex items-center gap-2 mb-4'>
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Suspicion Dashboard</h1>
<label className="flex items-center gap-2 mb-4">
<input
type='checkbox'
type="checkbox"
checked={filterBanned}
onChange={(e) => setFilterBanned(e.target.checked)}
/>
Hide banned users
</label>
<table className='min-w-full border'>
<thead className='bg-gray-100'>
<table className="min-w-full border">
<thead className="bg-gray-100">
<tr>
<th className='text-left px-2 py-1'>Username</th>
<th className="text-left px-2 py-1">Username</th>
<th>Score</th>
<th>Ghost</th>
<th>Clients</th>
@@ -67,29 +72,29 @@ function Dashboard() {
isBanned ? 'bg-red-100 text-red-900' : ''
}
>
<td className='px-2 py-1'>{user.username}</td>
<td className='text-center'>
<td className="px-2 py-1">{user.username}</td>
<td className="text-center">
{user.suspicionScore}
</td>
<td className='text-center'>
<td className="text-center">
{user.ghostScore}
</td>
<td className='text-center'>
<td className="text-center">
{user.multiClientCount}
</td>
<td className='text-center'>
<td className="text-center">
{user.channelCount}
</td>
<td className='text-center'>
<td className="text-center">
{user.fastReactionCount ?? 0}
</td>
<td className='text-center'>
<td className="text-center">
{user.accountAgeDays}
</td>
<td className='text-center'>
<td className="text-center">
{isBanned ? (
<button
className='text-sm text-blue-700 underline'
className="text-sm text-blue-700 underline"
onClick={() =>
api
.post('/userunban', {
@@ -115,7 +120,7 @@ function Dashboard() {
</button>
) : (
<button
className='text-sm text-red-700 underline'
className="text-sm text-red-700 underline"
onClick={() =>
api
.post('/userban', {

View File

@@ -7,10 +7,10 @@ function Login() {
const discordLoginUrl = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=identify+guilds`;
return (
<div className='flex items-center justify-center h-screen'>
<div className="flex items-center justify-center h-screen">
<a
href={discordLoginUrl}
className='bg-indigo-600 text-white px-6 py-3 rounded-md text-lg font-semibold'
className="bg-indigo-600 text-white px-6 py-3 rounded-md text-lg font-semibold"
>
Login with Discord
</a>

View File

@@ -19,6 +19,7 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true

2056
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "discord-spywatcher",
"version": "1.0.0",
"private": true,
"description": "Discord Spywatcher - Backend and Frontend monorepo",
"scripts": {
"prepare": "husky",
"lint": "npm run lint --prefix backend && npm run lint --prefix frontend",
"lint:fix": "npm run lint:fix --prefix backend && npm run lint:fix --prefix frontend",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"type-check": "npm run type-check --prefix backend && npm run type-check --prefix frontend"
},
"devDependencies": {
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"husky": "^9.0.0",
"lint-staged": "^15.0.0"
},
"lint-staged": {
"backend/**/*.ts": [
"npx eslint --fix",
"npx prettier --write"
],
"frontend/**/*.{ts,tsx}": [
"npx eslint --fix",
"npx prettier --write"
],
"*.{json,md}": [
"npx prettier --write"
]
}
}