feat: setup Next.js web project with Drizzle + PostgreSQL schema (#35) #42

Merged
maximus merged 7 commits from issue-35-web-setup into master 2026-04-06 16:58:05 +00:00
9 changed files with 276 additions and 0 deletions
Showing only changes of commit 0369597eb6 - Show all commits

161
web/package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "web",
"version": "0.1.0",
"dependencies": {
"@logto/next": "^4.2.9",
"@types/pg": "^8.20.0",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",
@ -288,6 +289,15 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@edge-runtime/cookies": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@edge-runtime/cookies/-/cookies-5.0.2.tgz",
"integrity": "sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@ -1911,6 +1921,53 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@logto/client": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@logto/client/-/client-3.1.7.tgz",
"integrity": "sha512-t/5wXMhiXtmbmP6Cmcl4uMsYetq21vSZuYZztPHXv6QX0dx7lSKBvYi/65ERoS+fmNmtV2/i4Ojf1U41o0TLPQ==",
"license": "MIT",
"dependencies": {
"@logto/js": "^6.1.1",
"@silverhand/essentials": "^2.9.3",
"camelcase-keys": "^9.1.3",
"jose": "^5.2.2"
}
},
"node_modules/@logto/js": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@logto/js/-/js-6.1.1.tgz",
"integrity": "sha512-G0lRS7VyOXdB06WYajEh9Kq2E3m11JshiKIKLj6LRPI1qZ06JYQ+Jsej3K60/4OIZMSzUas4FVnY+ORrhDdktA==",
"license": "MIT",
"dependencies": {
"@silverhand/essentials": "^2.9.3",
"camelcase-keys": "^9.1.3"
}
},
"node_modules/@logto/next": {
"version": "4.2.9",
"resolved": "https://registry.npmjs.org/@logto/next/-/next-4.2.9.tgz",
"integrity": "sha512-5HPt6UaQ23u+SNvmMN1Qhvxcuv9Yb7Ldz/I078Y8pLVCUWjhbgmZp/qCGg49fHIdilV3u1rjPFf5ndvj06Y8wg==",
"license": "MIT",
"dependencies": {
"@edge-runtime/cookies": "^5.0.0",
"@logto/node": "^3.1.9",
"cookie": "^1.0.0"
},
"peerDependencies": {
"next": ">=12"
}
},
"node_modules/@logto/node": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/@logto/node/-/node-3.1.9.tgz",
"integrity": "sha512-ApbUf3tWZYtMt6KJZo+bfms+5WcR7Cuz3dE9mVoPuo/joA08aU18fSe3L7VXqFu0nUJnG8BZi7ngoqCJEkQTig==",
"license": "MIT",
"dependencies": {
"@logto/client": "^3.1.7",
"@silverhand/essentials": "^2.9.3",
"js-base64": "^3.7.4"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -2123,6 +2180,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@silverhand/essentials": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/@silverhand/essentials/-/essentials-2.9.3.tgz",
"integrity": "sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==",
"license": "MIT",
"engines": {
"node": ">=18.12.0",
"pnpm": "^10.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -3469,6 +3536,36 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
"license": "MIT",
"dependencies": {
"camelcase": "^8.0.0",
"map-obj": "5.0.0",
"quick-lru": "^6.1.1",
"type-fest": "^4.3.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001786",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
@ -3546,6 +3643,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5528,6 +5638,21 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -5972,6 +6097,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/map-obj": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -6654,6 +6791,18 @@
],
"license": "MIT"
},
"node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@ -7971,6 +8120,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",

View file

@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@logto/next": "^4.2.9",
"@types/pg": "^8.20.0",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",

View file

@ -0,0 +1,17 @@
import { handleSignIn } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
import { NextRequest } from 'next/server';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
try {
await handleSignIn(logtoConfig, searchParams);
redirect('/');
} catch {
redirect('/?error=auth');
}
}

View file

@ -0,0 +1,9 @@
import { signIn } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
import { NextRequest } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
await signIn(logtoConfig, `${logtoConfig.baseUrl}/api/logto/callback`);
}

View file

@ -0,0 +1,8 @@
import { signOut } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
export const dynamic = 'force-dynamic';
export async function GET() {
await signOut(logtoConfig, `${logtoConfig.baseUrl}`);
}

20
web/src/app/auth/page.tsx Normal file
View file

@ -0,0 +1,20 @@
import Link from 'next/link';
export default function AuthPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-[#FFF8F0]">
<div className="text-center space-y-6 p-8">
<h1 className="text-3xl font-bold text-[#1A1A1A]">Simpl-Liste</h1>
<p className="text-[#6B6B6B]">
Connectez-vous avec votre Compte Maximus
</p>
<Link
href="/api/logto/sign-in"
className="inline-block px-6 py-3 bg-[#4A90A4] text-white rounded-lg font-medium hover:bg-[#3A7389] transition-colors"
>
Se connecter
</Link>
</div>
</div>
);
}

16
web/src/lib/auth.ts Normal file
View file

@ -0,0 +1,16 @@
import { getLogtoContext } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
export async function getAuthenticatedUser() {
const context = await getLogtoContext(logtoConfig);
if (!context.isAuthenticated || !context.claims?.sub) {
return null;
}
return {
userId: context.claims.sub,
email: context.claims.email,
name: context.claims.name,
};
}

11
web/src/lib/logto.ts Normal file
View file

@ -0,0 +1,11 @@
import type { LogtoNextConfig } from '@logto/next';
export const logtoConfig: LogtoNextConfig = {
endpoint: process.env.LOGTO_ENDPOINT!,
appId: process.env.LOGTO_APP_ID!,
appSecret: process.env.LOGTO_APP_SECRET!,
baseUrl: process.env.LOGTO_BASE_URL || 'http://localhost:3000',
cookieSecret: process.env.LOGTO_COOKIE_SECRET!,
cookieSecure: process.env.NODE_ENV === 'production',
scopes: ['openid', 'profile', 'email'],
};

33
web/src/middleware.ts Normal file
View file

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Let Logto API routes and health endpoint pass through
if (pathname.startsWith('/api/logto') || pathname.startsWith('/api/health')) {
return NextResponse.next();
}
// Protected routes: check for Logto session cookie
// The Logto SDK stores session data in a cookie named `logto:<appId>`
const hasSession = request.cookies.getAll().some(
(cookie) => cookie.name.startsWith('logto:')
);
if (!hasSession && !pathname.startsWith('/auth')) {
return NextResponse.redirect(new URL('/auth', request.url));
}
if (hasSession && pathname === '/auth') {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|api/logto|api/health).*)',
],
};