feat: implement user authentication with GitHub OAuth, create database schema, and enhance navigation

This commit is contained in:
yuanhau 2025-05-07 10:52:51 +08:00
parent d773473eb0
commit 98ffbec764
12 changed files with 167 additions and 7 deletions

View file

@ -1,4 +1,9 @@
S3_ACCESS_KEY="" S3_ACCESS_KEY=""
S3_SECRET_KEY="" S3_SECRET_KEY=""
S3_BUCKETNAME="" S3_BUCKETNAME=""
S3_ENDPOINT="" # Your S3 server, This can be Cloudflare R2, AWS S3, or just your own Minio infra. S3_ENDPOINT=""
NUXT_GITHUB_CLIENT_ID=""
NUXT_GITHUB_CLIENT_SECRET=""
POSTGRES_URL=""

View file

@ -0,0 +1,13 @@
# 新聞解析 / News Analyze
## Stack:
- Postgres
- Passport.js
- Tailwind
- Nuxt
- Animate.css
- GSAP
- Zeabur
- Minio S3
- Nuxt i18N
- BunJS

View file

@ -19,6 +19,7 @@
"bootstrap-icons": "^1.12.1", "bootstrap-icons": "^1.12.1",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"nuxt": "^3.17.2", "nuxt": "^3.17.2",
"passport-github2": "^0.1.12",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"tailwindcss": "3", "tailwindcss": "3",
"tailwindcss-animatecss": "^3.0.5", "tailwindcss-animatecss": "^3.0.5",
@ -671,6 +672,8 @@
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"base64url": ["base64url@3.0.1", "", {}, "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
@ -1505,6 +1508,8 @@
"nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="], "nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="],
"oauth": ["oauth@0.10.2", "", {}, "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
@ -1565,6 +1570,12 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"passport-github2": ["passport-github2@0.1.12", "", { "dependencies": { "passport-oauth2": "1.x.x" } }, "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw=="],
"passport-oauth2": ["passport-oauth2@1.8.0", "", { "dependencies": { "base64url": "3.x.x", "oauth": "0.10.x", "passport-strategy": "1.x.x", "uid2": "0.0.x", "utils-merge": "1.x.x" } }, "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA=="],
"passport-strategy": ["passport-strategy@1.0.0", "", {}, "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA=="],
"path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], "path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
@ -1965,6 +1976,8 @@
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
"uid2": ["uid2@0.0.4", "", {}, "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA=="],
"ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="], "ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="],
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
@ -2017,6 +2030,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="],

View file

@ -25,18 +25,19 @@ const toggleDropdown = () => {
</div> </div>
<div class="text-[0.9em] left-1/2 absolute transform -translate-x-1/2 space-x-4 items-center"> <div class="text-[0.9em] left-1/2 absolute transform -translate-x-1/2 space-x-4 items-center">
<NuxtLink <NuxtLink
:to="localePath('home')" :to="localePath('/home')"
class="hover:text-blue-500 cursor-pointer transiton-all duration-100" class="hover:text-blue-500 cursor-pointer transiton-all duration-100"
>{{ t("nav.home") }}</NuxtLink >{{ t("nav.home") }}</NuxtLink
> >
&nbsp; &nbsp;
<NuxtLink <NuxtLink
:to="localePath('dailybriefing')" :to="localePath('/dailybriefing')"
class="hover:text-blue-500 cursor-pointer transiton-all duration-100" class="hover:text-blue-500 cursor-pointer transiton-all duration-100"
>{{ t("nav.dailybriefing") }}</NuxtLink >{{ t("nav.dailybriefing") }}</NuxtLink
> >
</div> </div>
<div class="relative"> <div class="flex flex-row align-center justify-center text-center">
<div class="relative ml-0">
<button <button
@click="toggleDropdown" @click="toggleDropdown"
class="flex items-center space-x-1 px-4 py-2 rounded hover:bg-gray-900 transition-all duration-100 mr-5" class="flex items-center space-x-1 px-4 py-2 rounded hover:bg-gray-900 transition-all duration-100 mr-5"
@ -56,7 +57,6 @@ const toggleDropdown = () => {
/> />
</svg> </svg>
</button> </button>
<Transition <Transition
enter-active-class="animate__animated animate__fadeInDown animate_fastest" enter-active-class="animate__animated animate__fadeInDown animate_fastest"
leave-active-class="animate__animated animate__fadeOutUp animate_fastest" leave-active-class="animate__animated animate__fadeOutUp animate_fastest"
@ -77,6 +77,14 @@ const toggleDropdown = () => {
</div> </div>
</Transition> </Transition>
</div> </div>
<div class="mr-2 ml-0">
<NuxtLink :to="localePath('/system/login')">
<button class="text-white hover:text-[#C6C6C6] transition-all duration-150">
<i class="bi bi-person text-3xl"></i>
</button>
</NuxtLink>
</div>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>

50
createDatabase.ts Normal file
View file

@ -0,0 +1,50 @@
import { sql } from "bun";
const createUsers = await sql`
create table if not exists users (
uuid text primary key,
created_at timestampz default current_timestamp,
username text not null unique,
oauthProvider text not null,
avatarUrl text not null,
email text not null,
oauthProviderGivenId text not null
);
`;
const createNewsProviders = await sql`
create table if not exists newsProviders (
uuid text primary key,
title text not null,
slug text unique,
website text not null,
description text not null,
facebookUrl text,
twitterUrl text,
threadsUrl text,
logoUrl text not null,
lean text not null
)
`
const createAdminPosts = await sql`
create table if not exists adminPosts (
uuid text primary key,
slug text not null unique,
content text not null,
created_at timestampz default current_timestamp,
byUser text not null
)
`
const adminUsers = await sql`
create table if not exists adminUsers (
uuid text primary key,
username text not null unique,
passwordHash text not null,
created_at timestampz default current_timestamp,
lastlogged_at timestampz default current_timestamp,
)
`
console.log("Creation Complete");

7
layouts/admin.vue Normal file
View file

@ -0,0 +1,7 @@
<script setup lang="ts">
</script>
<template>
<main>
<slot />
</main>
</template>

View file

@ -8,7 +8,8 @@
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"prettier": "prettier --write ." "prettier": "prettier --write .",
"createdb": "bun run createDatabase.ts"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/noto-sans-tc": "^5.2.5", "@fontsource-variable/noto-sans-tc": "^5.2.5",
@ -26,6 +27,7 @@
"bootstrap-icons": "^1.12.1", "bootstrap-icons": "^1.12.1",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"nuxt": "^3.17.2", "nuxt": "^3.17.2",
"passport-github2": "^0.1.12",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"tailwindcss": "3", "tailwindcss": "3",
"tailwindcss-animatecss": "^3.0.5", "tailwindcss-animatecss": "^3.0.5",

12
pages/admin/login.vue Normal file
View file

@ -0,0 +1,12 @@
<script lang="ts" setup>
definePageMeta({
layout: "admin"
})
</script>
<template>
<div class="flex justify-center min-h-screen w-full">
<input type="text"/>
<input type="password" />
<button>登入</button>
</div>
</template>

25
pages/system/login.vue Normal file
View file

@ -0,0 +1,25 @@
<template>
<div class="w-full min-h-screen flex items-center justify-center text-center">
<div class="border border-white w-[40%] p-16 justify-center align-center text-center rounded-md backdrop-blur-sm bg-gray-900">
<h1 class="text-2xl">Login / Register</h1>
<h4 class="text-sm">via OAuth Providers</h4>
<div class="m-4 flex flex-col gap-2">
<a href="/api/auth/google">
<button class="gap-3 px-10 justify-between align-center text-center bg-gray-500 hover:bg-gray-700 p-2 rounded-md transition-all duration-150">
<i class="bi bi-google"></i>&nbsp;&nbsp;<span>Google</span>
</button>
</a>
<a href="/api/auth/github">
<button class="gap-3 px-10 bg-gray-500 hover:bg-gray-700 p-2 rounded-md transition-all duration-150">
<i class="bi bi-github"></i>&nbsp;&nbsp;<span>Github</span>
</button>
</a>
<a href="/api/auth/discord">
<button class="gap-3 px-10 bg-gray-500 hover:bg-gray-700 p-2 rounded-md transition-all duration-150">
<i class="bi bi-discord"></i>&nbsp;&nbsp;<span>Discord</span>
</button>
</a>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,8 @@
export default defineEventHandler(async (event) => {
})
async function findUser(githubUser: any) {
console.log("Github User: " + githubUser);
}

View file

@ -0,0 +1,16 @@
import crypto from "node:crypto"
export default defineEventHandler(async (event) => {
const baseUrl = event.node.req.headers.host
const protocol = process.env.NODE_ENV === "production" ? "https": "http"
const clientId = process.env.NUXT_GITHUB_CLIENT_ID;
const callbackUrl = `${protocol}://${baseUrl}/api/auth/github/callback`;
const state = crypto.randomBytes(16).toString("hex");
setCookie(event, 'oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 10,
path: '/',
})
const authorizationUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=read:user,user:email&state=${state}`
await sendRedirect(event, authorizationUrl, 302)
})

View file

@ -14,7 +14,6 @@
} }
} }
/* noto-sans-tc-chinese-traditional-wght-normal */
@font-face { @font-face {
font-family: 'Noto Sans TC Variable'; font-family: 'Noto Sans TC Variable';
font-style: normal; font-style: normal;