From 5069965ba37dc6d17af29419bf5bda527f0703b2 Mon Sep 17 00:00:00 2001 From: kittentm Date: Sat, 1 Aug 2026 23:59:45 +0200 Subject: [PATCH] feat: link sfids --- app/composables/useAuth.js | 5 +- app/pages/oauth/index.vue | 11 +- app/pages/welcome/index.vue | 145 +++++++++++++++++++++++ bun.lock | 29 +++++ package.json | 1 + public/img_unknown_MiiIcon.png | Bin 0 -> 1651 bytes server/api/auth/token.post.ts | 36 +++++- server/api/user/complete-welcome.post.ts | 69 +++++++++++ server/api/user/lookup-nnas.post.ts | 36 ++++++ server/utils/db.ts | 39 ++++++ server/utils/nnasDb.ts | 11 ++ 11 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 app/pages/welcome/index.vue create mode 100644 public/img_unknown_MiiIcon.png create mode 100644 server/api/user/complete-welcome.post.ts create mode 100644 server/api/user/lookup-nnas.post.ts create mode 100644 server/utils/db.ts create mode 100644 server/utils/nnasDb.ts diff --git a/app/composables/useAuth.js b/app/composables/useAuth.js index 7f86707..0a14f5d 100644 --- a/app/composables/useAuth.js +++ b/app/composables/useAuth.js @@ -45,11 +45,12 @@ export const useAuth = () => { window.history.replaceState({}, document.title, window.location.pathname) await verifySession() startLiveSessionPolling() - return true + + return { success: true, isFirstLogin: data.isFirstLogin } } catch (error) { console.error('Code exchange failed:', error) clearSession() - return false + return { success: false, isFirstLogin: false } } finally { isInitialized.value = true } diff --git a/app/pages/oauth/index.vue b/app/pages/oauth/index.vue index cffd8a8..97b1d7f 100644 --- a/app/pages/oauth/index.vue +++ b/app/pages/oauth/index.vue @@ -14,10 +14,14 @@ onMounted(async () => { if (route.query.code) { isLoggingIn.value = true - const success = await exchangeCodeForToken(route.query.code) + const result = await exchangeCodeForToken(route.query.code) - if (success) { - await navigateTo('/') + if (result?.success) { + if (result.isFirstLogin) { + await navigateTo('/welcome') + } else { + await navigateTo('/') + } } else { errorMessage.value = 'Access Denied' isLoggingIn.value = false @@ -31,7 +35,6 @@ const handleLogin = () => { errorMessage.value = '' const redirectUri = `${window.location.origin}/oauth` - const authUrl = new URL( `${config.public.keycloakUrl}/realms/${config.public.keycloakRealm}/protocol/openid-connect/auth` ) diff --git a/app/pages/welcome/index.vue b/app/pages/welcome/index.vue new file mode 100644 index 0000000..d5faea3 --- /dev/null +++ b/app/pages/welcome/index.vue @@ -0,0 +1,145 @@ + + + \ No newline at end of file diff --git a/bun.lock b/bun.lock index 155f7f5..853046d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@nuxt/ui": "^4.10.0", "keycloak-js": "^26.2.4", "nuxt": "^4.5.0", + "pg": "^8.22.0", "vite-tsconfig-paths": "^6.1.1", "vue": "^3.5.40", "vue-router": "^5.2.0", @@ -1148,6 +1149,22 @@ "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + "pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.15.0", "", {}, "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], @@ -1214,6 +1231,14 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "pretty-bytes": ["pretty-bytes@7.1.1", "", {}, "sha512-X+vn9z8nOFZQlxOLmfJ0iKDdMD7jYTsTW12OAlCpdoE3Igik6L37pugIZi+N3usuyp5McfgKPWi12q3zvHLeGQ=="], @@ -1342,6 +1367,8 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], @@ -1520,6 +1547,8 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], diff --git a/package.json b/package.json index 6130e9c..a2c79c7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@nuxt/ui": "^4.10.0", "keycloak-js": "^26.2.4", "nuxt": "^4.5.0", + "pg": "^8.22.0", "vite-tsconfig-paths": "^6.1.1", "vue": "^3.5.40", "vue-router": "^5.2.0" diff --git a/public/img_unknown_MiiIcon.png b/public/img_unknown_MiiIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..61d4e7c650b8be2d7869180449abc1fccc6a11e4 GIT binary patch literal 1651 zcmY*Y2~ZPf6b^?R!2m*FAOk2@41$0bl?ql80Rd^c2o5dK5fsr-s+GgyP}Cf|n+-5S zYaEYMJU}Xnt-}D-RA8hE3OJxZv?w=t)FX%jqEY&G|JIrQ^XKL5d*A!s_x}Bt!o=%2 zY#y7%VsWCQ!Z*TyD*dxC0oxl%Lc+Kai%p1tM^dGd%47sZfdf`S5a4{=gi?uz{K*eD1VA_|2hMLkPef5b4k?JxL6QW1NYTuUPC6@x z5DrL03qIhHP6*O?k^~3Jp#)kWHmOvK@W6m1A#%XOXkY>MZ+9j_3j?7g+&1so1z5d#3AFU<_+&sgY%n$mg*g6_h+l#e7xFiODzLOVc02{@nu zO@W%ywV)Qqk%bfj901wCtW?t100x0;!(0-mLYie#DX`H;!IXec%Vu5~-3VZeFpva6 zGzi3Dqf-LGPy#+p%SZwwFw;En5{rt^q`;ro(*6W)@ObVUqN%|TGu<`P z0A1MM)&uiH8v^R@ra74$wa*;&JKyK7e`v@`GQY1C zy!uFH;dj#$OHGO$6V=C0+jo4D_tL_oU(e zY>B^>C+1kc|eyix$Eg$}J;H?ZL4M|z~Qx6v{-Tl$w!UQMh=U2BSbJLv6x-?}GabA9yrNs1)|1Z684&Rbu z+^&7q(fGstoS`vhRp@GXImyN6PfL=fM!Z|Z&w8CyhbF&rsR^W-sxyyNZM%6B+i`8O z!#}qCo7St?Hl4@!N!|TRR^jaahsBkg3x&g%hR;Ye1$8gJ>*{ec{N}%Ps5FDKqt$k8 zaOz`r-=WJkvB{5wCY5SD+P>{}X-uGmUm{t>sf;}xSGP4yHkirNE#$vGv-{|3Vb@4ET1u-e17N-t0t1mtvFwI-8r5sCc%L+03@rtj{!Q9{guk8G|$beE4yUYDP<@!#J zXB6S$nErN4DuvrV&$46-&nrkJDeV$e3Pt5d%$R_|=b;VtOx<_P)o!oAq0HQF z){m>z*w{(^)wet6i97_p_#QK%M{wEl!|UYkBj@ACSc*v9si>d|N6ceneuZd_0JF`$ zB}rMCc%!%YWLjBZ`Qj4ZpmRpgF|mA0s#w1_sKTw|T$ra>y-XtCvH4A4@KEq`s@<4O zxqoA>tsHmxUsE0GGCdUZ?aP5XZtQ1`+6}*~cz@@R2J@ODY%3k=wzqQ5=A`Rg zcUdhp{5k2huaLzJ(dsdGeva?Nn;S@!6?@AlprA~L?zjT$K*-8ms|Dt)T?#J z!j7wA$@S)Ev!_4n&apcenyxLoE-Fc+YA2UcwO4SSzCkh`vi~i61^W&mI&rf3sZH&; lRYLv@|Eh1H;f?dOfu%R*CVm>pABAs^6&)cC|5cPK`v)%3A%Xw^ literal 0 HcmV?d00001 diff --git a/server/api/auth/token.post.ts b/server/api/auth/token.post.ts index 6a6d2fb..7c37488 100644 --- a/server/api/auth/token.post.ts +++ b/server/api/auth/token.post.ts @@ -1,3 +1,5 @@ +import { query } from '../../utils/db' + function parseJwt(token: string) { try { const base64Payload = token.split('.')[1] @@ -35,9 +37,7 @@ export default defineEventHandler(async (event) => { try { const response = await $fetch(tokenUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) @@ -51,11 +51,37 @@ export default defineEventHandler(async (event) => { }) } - return response + const keycloakId = decodedToken?.sub + const email = decodedToken?.email + + let isFirstLogin = false + + if (keycloakId) { + try { + const dbResult = await query( + `INSERT INTO users (keycloak_id, email) + VALUES ($1, $2) + ON CONFLICT (keycloak_id) DO UPDATE SET updated_at = CURRENT_TIMESTAMP + RETURNING first_login_completed`, + [keycloakId, email] + ) + + isFirstLogin = !dbResult.rows[0]?.first_login_completed + } catch (dbErr) { + console.error('[DB ERROR in token.post.ts]:', dbErr) + isFirstLogin = false + } + } + + return { + ...response, + isFirstLogin + } } catch (error: any) { + console.error('[AUTH ERROR in token.post.ts]:', error?.data || error) throw createError({ statusCode: error.statusCode || error.response?.status || 401, - statusMessage: error.data?.message || error.response?._data?.error_description || 'Failed to exchange code' + statusMessage: error.data?.message || error.response?._data?.error_description || 'Authentication failed' }) } }) \ No newline at end of file diff --git a/server/api/user/complete-welcome.post.ts b/server/api/user/complete-welcome.post.ts new file mode 100644 index 0000000..5908c1b --- /dev/null +++ b/server/api/user/complete-welcome.post.ts @@ -0,0 +1,69 @@ +import { query } from '../../utils/db' +import { queryNnas } from '../../utils/nnasDb' + +function parseJwt(token: string) { + try { + const base64Payload = token.split('.')[1] + return JSON.parse(Buffer.from(base64Payload, 'base64').toString('utf8')) + } catch { + return null + } +} + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const decoded = body?.token ? parseJwt(body.token) : null + + if (!decoded?.sub) { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + + const usernameInput = body?.sfid?.trim() + + if (!usernameInput) { + throw createError({ statusCode: 400, statusMessage: 'SFID is required' }) + } + + let nnasUser: { pid: number; username: string } | null = null + + try { + const nnasResult = await queryNnas( + `SELECT pid, username FROM users WHERE LOWER(username) = LOWER($1) LIMIT 1`, + [usernameInput] + ) + + if (nnasResult.rows.length === 0) { + throw createError({ + statusCode: 404, + statusMessage: `SFID "${usernameInput}" was not found in NNAS database.` + }) + } + + nnasUser = nnasResult.rows[0] + } catch (error: any) { + if (error.statusCode) throw error + console.error('[NNAS DB Error]:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to query NNAS database.' + }) + } + + await query( + `UPDATE users + SET sfid = $1, + pid = $2, + first_login_completed = TRUE, + updated_at = CURRENT_TIMESTAMP + WHERE keycloak_id = $3`, + [nnasUser.username, nnasUser.pid, decoded.sub] + ) + + return { + success: true, + user: { + sfid: nnasUser.username, + pid: nnasUser.pid + } + } +}) \ No newline at end of file diff --git a/server/api/user/lookup-nnas.post.ts b/server/api/user/lookup-nnas.post.ts new file mode 100644 index 0000000..98c69e5 --- /dev/null +++ b/server/api/user/lookup-nnas.post.ts @@ -0,0 +1,36 @@ +import { queryNnas } from '../../utils/nnasDb' + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const usernameInput = body?.sfid?.trim() + + if (!usernameInput) { + throw createError({ statusCode: 400, statusMessage: 'Username is required' }) + } + + try { + const nnasResult = await queryNnas( + `SELECT pid, username FROM users WHERE LOWER(username) = LOWER($1) LIMIT 1`, + [usernameInput] + ) + + if (nnasResult.rows.length === 0) { + throw createError({ + statusCode: 404, + statusMessage: `Account "${usernameInput}" was not found.` + }) + } + + return { + pid: nnasResult.rows[0].pid, + username: nnasResult.rows[0].username + } + } catch (error: any) { + if (error.statusCode) throw error + console.error('[NNAS Lookup Error]:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to look up NNAS profile' + }) + } +}) \ No newline at end of file diff --git a/server/utils/db.ts b/server/utils/db.ts new file mode 100644 index 0000000..a340e30 --- /dev/null +++ b/server/utils/db.ts @@ -0,0 +1,39 @@ +import pg from 'pg' + +const pool = new pg.Pool({ + host: process.env.POSTGRES_HOST || 'localhost', + port: Number(process.env.POSTGRES_PORT) || 5432, + database: process.env.POSTGRES_DB || 'mod_portal', + user: process.env.POSTGRES_USER || 'postgres', + password: process.env.POSTGRES_PASSWORD || 'postgres', +}) + +const initDb = async () => { + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + keycloak_id VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255), + first_login_completed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + sfid VARCHAR(255), + pid INTEGER + ); + `) + + await pool.query(` + ALTER TABLE users ADD COLUMN IF NOT EXISTS sfid VARCHAR(255); + ALTER TABLE users ADD COLUMN IF NOT EXISTS pid INTEGER; + `) + + console.log('[DB INIT]: Table "users" checked/updated successfully.') + } catch (err) { + console.error('[DB INIT ERROR]:', err) + } +} + +initDb() + +export const query = (text: string, params?: any[]) => pool.query(text, params) \ No newline at end of file diff --git a/server/utils/nnasDb.ts b/server/utils/nnasDb.ts new file mode 100644 index 0000000..06d6816 --- /dev/null +++ b/server/utils/nnasDb.ts @@ -0,0 +1,11 @@ +import pg from 'pg' + +const nnasPool = new pg.Pool({ + host: process.env.NNAS_POSTGRES_HOST || 'localhost', + port: Number(process.env.NNAS_POSTGRES_PORT) || 5432, + database: process.env.NNAS_POSTGRES_DB || 'NNAS', + user: process.env.NNAS_POSTGRES_USER || 'postgres', + password: process.env.NNAS_POSTGRES_PASSWORD || 'postgres', +}) + +export const queryNnas = (text: string, params?: any[]) => nnasPool.query(text, params) \ No newline at end of file