feat: link sfids

This commit is contained in:
kittentm 2026-08-01 23:59:45 +02:00
commit 5069965ba3
Signed by: kitten
GPG key ID: 394B4EABE4405A83
11 changed files with 371 additions and 11 deletions

View file

@ -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
}

View file

@ -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`
)

145
app/pages/welcome/index.vue Normal file
View file

@ -0,0 +1,145 @@
<script setup>
import { ref } from 'vue'
const { userToken } = useAuth()
const sfidInput = ref('')
const step = ref(1)
const foundUser = ref(null)
const isSearching = ref(false)
const isSubmitting = ref(false)
const errorMessage = ref('')
const handleLookup = async () => {
if (!sfidInput.value.trim()) return
isSearching.value = true
errorMessage.value = ''
try {
const res = await $fetch('/api/user/lookup-nnas', {
method: 'POST',
body: { sfid: sfidInput.value }
})
foundUser.value = res
step.value = 2
} catch (err) {
errorMessage.value = err.data?.statusMessage || err.data?.message || 'Failed to locate user account.'
} finally {
isSearching.value = false
}
}
const handleConfirm = async () => {
if (!foundUser.value) return
isSubmitting.value = true
errorMessage.value = ''
try {
await $fetch('/api/user/complete-welcome', {
method: 'POST',
body: {
token: userToken.value,
sfid: foundUser.value.username
}
})
await navigateTo('/')
} catch (err) {
errorMessage.value = err.data?.statusMessage || err.data?.message || 'Failed to complete setup.'
} finally {
isSubmitting.value = false
}
}
const handleReset = () => {
step.value = 1
foundUser.value = null
errorMessage.value = ''
}
</script>
<template>
<div class="login-wrapper">
<section class="settings-panel" style="max-width: 480px; margin: 0 auto;">
<div class="panel-header archivo-black">
WELCOME
</div>
<div v-if="step === 1" class="panel-body" style="padding: 2rem; text-align: center;">
<img src="/welcome5-1.png" alt="Welcome Image"
style="max-width: 160px; margin-bottom: 1rem; object-fit: contain;" />
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">
First Time Setup
</h2>
<p style="color: #8b949e; font-size: 0.9rem; margin-bottom: 1.5rem; line-height: 1.4;">
Please enter your SFID to link your account. This wont be used for login.
</p>
<form @submit.prevent="handleLookup">
<div style="margin-bottom: 1.25rem; text-align: left;">
<label for="sfid" style="display: block; font-size: 0.85rem; color: #c9d1d9; margin-bottom: 0.5rem;">
SFID <span style="color: #f85149;">*</span>
</label>
<input id="sfid" v-model="sfidInput" type="text" placeholder="e.g. redbinderischud" required
style="width: 100%; padding: 0.6rem 0.8rem; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.95rem; outline: none;" />
</div>
<p v-if="errorMessage" style="color: #f85149; font-size: 0.85rem; margin-bottom: 1rem;">
{{ errorMessage }}
</p>
<button type="submit" class="btn-login-action" style="width: 100%;" :disabled="isSearching">
<span v-if="isSearching" class="spinner sm btn-spinner"></span>
<span style="vertical-align: middle;">
{{ isSearching ? 'Looking up...' : 'Find Account →' }}
</span>
</button>
</form>
</div>
<div v-else class="panel-body" style="padding: 2rem; text-align: center;">
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1.5rem;">
Is this you?
</h2>
<div
style="background: #0d1117; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem;">
<img :src="`https://mii.spfn.net/${foundUser.pid}/main.png`" :alt="foundUser.username"
style="width: 96px; height: 96px; border-radius: 50%; background: #161b22; object-fit: contain; margin-bottom: 0.75rem;"
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'" />
<div style="font-size: 1.1rem; font-weight: 600; color: #f0f6fc;">
{{ foundUser.username }}
</div>
<div style="font-size: 0.8rem; color: #8b949e; margin-top: 0.25rem;">
PID: {{ foundUser.pid }}
</div>
</div>
<p v-if="errorMessage" style="color: #f85149; font-size: 0.85rem; margin-bottom: 1rem;">
{{ errorMessage }}
</p>
<div style="display: flex; gap: 0.75rem;">
<button type="button"
style="flex: 1; padding: 0.6rem; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-weight: 500; cursor: pointer;"
:disabled="isSubmitting" @click="handleReset">
Not Me
</button>
<button type="button" class="btn-login-action" style="flex: 1;" :disabled="isSubmitting"
@click="handleConfirm">
<span v-if="isSubmitting" class="spinner sm btn-spinner"></span>
<span style="vertical-align: middle;">
{{ isSubmitting ? 'Linking...' : 'Yes, That\'s Me!' }}
</span>
</button>
</div>
</div>
</section>
</div>
</template>

View file

@ -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=="],

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -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<any>(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'
})
}
})

View file

@ -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
}
}
})

View file

@ -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'
})
}
})

39
server/utils/db.ts Normal file
View file

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

11
server/utils/nnasDb.ts Normal file
View file

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