Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,12 @@ DATABASE_URL=postgres://gruppe@localhost:5433/norwegian_explorer

# På VM-en (koble til via socket i stedet for nettverk):
# DATABASE_URL=postgres://gruppe@%2Fvar%2Frun%2Fpostgresql/norwegian_explorer

# Lokal testing av passordtilbakestilling: lagrer e-post som .eml, sender ingenting.
# APP_PUBLIC_URL=http://localhost:5173/
# PASSWORD_RESET_OUTBOX=/tmp/norwegian-explorer-mail

# For ekte levering via en konfigurert lokal mailtjeneste (ikke testet her):
# SENDMAIL_PATH=/usr/sbin/sendmail
# MAIL_FROM=Norwegian Explorer <no-reply@example.no>
# APP_PUBLIC_URL=https://din-app.example.no/
83 changes: 73 additions & 10 deletions server/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resetDeliveryAvailable, sendPasswordReset } from './passwordMail.ts'
import { createServer } from 'node:http'
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
Expand Down Expand Up @@ -28,6 +29,11 @@ const appSchemaSetup = pool.query(`
password_salt bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS norwegian_explorer_app.password_resets (
token_hash bytea PRIMARY KEY,
user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS norwegian_explorer_app.sessions (
token_hash bytea PRIMARY KEY,
user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE,
Expand Down Expand Up @@ -111,6 +117,11 @@ appSchemaReady.catch(error => console.error('Kunne ikke klargjøre appens tabell
routeCatalogReady.catch(error => console.error(error.message))
hasHomeMunicipality.catch(()=>undefined)

function validateEmail(email: string | null) {
const value = email?.trim().toLowerCase() || ''
if (value.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) throw new GraphQLError('Skriv inn en gyldig e-postadresse.')
return value
}
function validateCredentials(username:string, password:string) {
const normalized = username.trim()
if (!/^[\p{L}\p{N}_.-]{3,30}$/u.test(normalized)) throw new GraphQLError('Brukernavnet må være 3–30 tegn og kan inneholde bokstaver, tall, punktum, bindestrek og understrek.')
Expand Down Expand Up @@ -270,7 +281,9 @@ const schema = createSchema({
routeStops(token: String!, geojson: String!): RouteStopPage!
}
type Mutation {
register(username: String!, password: String!): AuthPayload!
register(username: String!, password: String!, email: String!): AuthPayload!
requestPasswordReset(email: String!): Boolean!
resetPassword(token: String!, newPassword: String!): Boolean!
login(username: String!, password: String!): AuthPayload!
logout(token: String!): Boolean!
toggleFavorite(token: String!, routeId: ID!): UserProfile!
Expand Down Expand Up @@ -466,27 +479,66 @@ const schema = createSchema({
return user ? profileFor(user) : null
},
}, Mutation: {
register: async (_:unknown, { username, password }:ApiArgs) => {
register: async (_:unknown, { username, password, email }:ApiArgs) => {
await appSchemaReady
checkAuthAttempt(username)
const cleanUsername = validateCredentials(username, password)
const cleanEmail = validateEmail(email)
const salt = randomBytes(16)
const hash = await passwordHash(password, salt)
const client = await pool.connect()
try {
await client.query('BEGIN')
const { rows } = await client.query(`INSERT INTO norwegian_explorer_app.users(username,password_hash,password_salt)
VALUES ($1,$2,$3) RETURNING id,username`, [cleanUsername, hash, salt])
const { rows } = await client.query(`INSERT INTO norwegian_explorer_app.users(username,password_hash,password_salt,email)
VALUES ($1,$2,$3,$4) RETURNING id,username,email`, [cleanUsername, hash, salt, cleanEmail])
const token = await createSession(client, rows[0].id)
await client.query('COMMIT')
return { token, user: { username: rows[0].username, homeMunicipality:null, favorites: [], trips: [], goals: [],
return { token, user: { username: rows[0].username, email: cleanEmail, homeMunicipality:null, favorites: [], trips: [], goals: [],
stats: { completedCount: 0, plannedCount: 0, totalLengthMeters: 0, countyCount: 0 } } }
} catch (error) {
await client.query('ROLLBACK')
if (error && typeof error==='object' && 'code' in error && error.code === '23505') throw new GraphQLError('Brukernavnet er allerede i bruk.')
throw error
} finally { client.release() }
},
requestPasswordReset: async (_:unknown, { email }:ApiArgs) => {
await appSchemaReady
const cleanEmail = validateEmail(email)
checkAuthAttempt(`reset:${cleanEmail}`)
if (!resetDeliveryAvailable()) throw new GraphQLError('Passordtilbakestilling er ikke tilgjengelig ennå. Kontakt prosjektgruppen.')
const {rows} = await pool.query<{id:string}>('SELECT id FROM norwegian_explorer_app.users WHERE lower(email)=$1', [cleanEmail])
for (const user of rows) {
const resetToken = randomBytes(32).toString('base64url')
await pool.query("INSERT INTO norwegian_explorer_app.password_resets(token_hash,user_id,expires_at) VALUES ($1,$2,now()+interval '30 minutes')", [tokenHash(resetToken), user.id])
try { await sendPasswordReset(cleanEmail, resetToken) }
catch { await pool.query('DELETE FROM norwegian_explorer_app.password_resets WHERE token_hash=$1',[tokenHash(resetToken)]); throw new GraphQLError('Kunne ikke sende e-post akkurat nå. Prøv igjen senere.') }
}
await pool.query('DELETE FROM norwegian_explorer_app.password_resets WHERE expires_at<=now()')
return true
},
resetPassword: async (_:unknown, {token, newPassword}:ApiArgs) => {
await appSchemaReady
checkAuthAttempt('password-reset')
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new GraphQLError('Lenken er ugyldig eller utløpt. Be om en ny lenke.')
if (newPassword.length<10 || newPassword.length>200) throw new GraphQLError('Passordet må være mellom 10 og 200 tegn.')
const client = await pool.connect()
try {
await client.query('BEGIN')
const {rows} = await client.query<{user_id:string}>('SELECT user_id FROM norwegian_explorer_app.password_resets WHERE token_hash=$1 AND expires_at>now()', [tokenHash(token)])
if (!rows.length) throw new GraphQLError('Lenken er ugyldig eller utløpt. Be om en ny lenke.')
const userId = rows[0].user_id
await client.query('SELECT id FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE',[userId])
const valid = await client.query('SELECT 1 FROM norwegian_explorer_app.password_resets WHERE token_hash=$1 AND expires_at>now()', [tokenHash(token)])
if (!valid.rowCount) throw new GraphQLError('Lenken er ugyldig eller utløpt. Be om en ny lenke.')
const salt = randomBytes(16)
await client.query('UPDATE norwegian_explorer_app.users SET password_hash=$2,password_salt=$3 WHERE id=$1',[userId,await passwordHash(newPassword,salt),salt])
await client.query('DELETE FROM norwegian_explorer_app.sessions WHERE user_id=$1',[userId])
await client.query('DELETE FROM norwegian_explorer_app.password_resets WHERE user_id=$1',[userId])
await client.query('COMMIT')
return true
} catch(error) { await client.query('ROLLBACK'); throw error }
finally { client.release() }
},
login: async (_:unknown, { username, password }:ApiArgs) => {
checkAuthAttempt(username)
if(username.length>100 || password.length>200)throw new GraphQLError('Feil brukernavn eller passord.')
Expand Down Expand Up @@ -563,6 +615,7 @@ const schema = createSchema({
const hash = await passwordHash(newPassword, salt)
await client.query('UPDATE norwegian_explorer_app.users SET password_hash=$2,password_salt=$3 WHERE id=$1', [user.id,hash,salt])
await client.query('DELETE FROM norwegian_explorer_app.sessions WHERE user_id=$1', [user.id])
await client.query('DELETE FROM norwegian_explorer_app.password_resets WHERE user_id=$1', [user.id])
await client.query('COMMIT')
return true
} catch (error) {
Expand Down Expand Up @@ -624,17 +677,27 @@ const schema = createSchema({
},
updateProfile: async (_:unknown, { token, email, phone, avatar }:ApiArgs) => {
const user = await requireUser(token)
const cleanEmail = email?.trim() || null
const cleanEmail = validateEmail(email)
const cleanPhone = phone?.trim() || null
const cleanAvatar = avatar?.trim() || null
if(cleanEmail && cleanEmail.length>254)throw new GraphQLError('E-postadressen er for lang.')
if (cleanEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) throw new GraphQLError('Skriv inn en gyldig e-postadresse.')
if (cleanPhone && !/^\+?[0-9 ()-]{6,20}$/.test(cleanPhone)) throw new GraphQLError('Skriv inn et gyldig telefonnummer.')
if (cleanAvatar && (!/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/.test(cleanAvatar) || cleanAvatar.length > 600000)) throw new GraphQLError('Profilbildet må være et bilde på under 450 KB.')
const { rows } = await pool.query(`UPDATE norwegian_explorer_app.users
SET email=$2,phone=$3,avatar=$4 WHERE id=$1 RETURNING id,username,email,phone,avatar,${await homeColumn()}`,
[user.id, cleanEmail, cleanPhone, cleanAvatar])
return profileFor(rows[0])
const client = await pool.connect()
let updated: AppUser
try {
await client.query('BEGIN')
const {rows} = await client.query<AppUser>('SELECT id, email FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE',[user.id])
if (rows[0].email?.toLowerCase() !== cleanEmail) await client.query('DELETE FROM norwegian_explorer_app.password_resets WHERE user_id=$1',[user.id])
const result = await client.query<AppUser>(`UPDATE norwegian_explorer_app.users
SET email=$2,phone=$3,avatar=$4 WHERE id=$1 RETURNING id,username,email,phone,avatar,${await homeColumn()}`,
[user.id, cleanEmail, cleanPhone, cleanAvatar])
updated = result.rows[0]
await client.query('COMMIT')
} catch(error) { await client.query('ROLLBACK'); throw error }
finally { client.release() }
return profileFor(updated)
},
}, Municipality: {
// Regnes bare ut når klienten ber om feltet
Expand Down
26 changes: 26 additions & 0 deletions server/passwordMail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { spawn } from 'node:child_process'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'

export function resetDeliveryAvailable() { return Boolean(process.env.SENDMAIL_PATH || (process.env.PASSWORD_RESET_OUTBOX && process.env.NODE_ENV !== 'production')) }
export async function sendPasswordReset(email: string, token: string) {
const base = new URL(process.env.APP_PUBLIC_URL || 'http://localhost:5173/')
if (!['http:', 'https:'].includes(base.protocol)) throw new Error('Invalid APP_PUBLIC_URL')
base.hash = `reset-password/${token}`
const from = process.env.MAIL_FROM || 'Norwegian Explorer <no-reply@localhost>'
if (/[\r\n]/.test(from + email)) throw new Error('Invalid mail address')
const message = `From: ${from}\nTo: ${email}\nSubject: Tilbakestill passordet ditt\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\n\nÅpne lenken for å velge et nytt passord:\n${base.href}\n\nLenken gjelder i 30 minutter og kan brukes én gang. Hvis du ikke ba om dette, kan du se bort fra meldingen.\n`
if (process.env.SENDMAIL_PATH) {
await new Promise<void>((resolve, reject) => {
const child = spawn(process.env.SENDMAIL_PATH!, ['-t', '-i'], { stdio: ['pipe', 'ignore', 'ignore'], timeout: 15000 })
child.once('error', reject)
child.stdin.on('error', reject)
child.once('close', code => code === 0 ? resolve() : reject(new Error('Mail delivery failed')))
child.stdin.end(message)
})
} else if (process.env.PASSWORD_RESET_OUTBOX && process.env.NODE_ENV !== 'production') {
await mkdir(process.env.PASSWORD_RESET_OUTBOX, { recursive: true, mode: 0o700 })
await writeFile(join(process.env.PASSWORD_RESET_OUTBOX, `${randomUUID()}.eml`), message, {mode: 0o600})
} else throw new Error('Mail delivery is not configured')
}
6 changes: 3 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import Profile, { AuthGate } from './Profile'

export default function App() {
const [view,setView] = useState<'explore'|'profile'>(()=>window.location.hash.startsWith('#profile')?'profile':'explore')
const [showHome,setShowHome]=useState(()=>!(window.location.hash==='#explore' || window.location.hash.startsWith('#profile')))
const [showLogin,setShowLogin]=useState(false)
const [showHome,setShowHome]=useState(()=>!(window.location.hash==='#explore' || window.location.hash.startsWith('#profile') || window.location.hash.startsWith('#reset-password/')))
const [showLogin,setShowLogin]=useState(()=>window.location.hash.startsWith('#reset-password/'))
const [guest,setGuest]=useState(()=>window.location.hash==='#explore' || (window.location.hash.startsWith('#profile') && Boolean(localStorage.getItem(TOKEN_KEY))))
const [authenticated, setAuthenticated] = useState(false)
const handleAuthenticated = useCallback(() => {setAuthenticated(true);setGuest(false);setShowLogin(false)}, [])
Expand All @@ -33,7 +33,7 @@ export default function App() {
// Turen vises i panelet oppå kartet, som ligger øverst på Utforsk-siden
window.requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: 'smooth' }))
}
if(showLogin&&!authenticated)return <AuthGate onAuthenticated={handleAuthenticated} onHome={()=>{setShowLogin(false);setShowHome(true)}} onSkip={()=>{setGuest(true);setShowLogin(false);setShowHome(false);setView('explore')}} />
if(showLogin&&(!authenticated||window.location.hash.startsWith('#reset-password/')))return <AuthGate onAuthenticated={handleAuthenticated} onHome={()=>{setShowLogin(false);setShowHome(true)}} onSkip={()=>{setGuest(true);setShowLogin(false);setShowHome(false);setView('explore')}} />
if(showHome)return <Home authenticated={authenticated} onExploreClick={()=>{setGuest(!authenticated);setShowHome(false);setView('explore')}} onProfileClick={()=>{setView('profile');setShowHome(false)}} onLoginClick={()=>setShowLogin(true)} onSelectRoute={item=>{openRoute(item);setGuest(!authenticated);setShowHome(false)}} />
if(!authenticated&&!guest)return <AuthGate onAuthenticated={handleAuthenticated} onHome={()=>setShowHome(true)} onSkip={()=>{setGuest(true);setView('explore')}} />

Expand Down
7 changes: 7 additions & 0 deletions src/Profile.css
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,10 @@
.profile-content { min-width: 0; }
.profile-content > div > h3:first-child { margin-top: 0; }
@media(max-width: 720px) { .profile-split { grid-template-columns: 1fr; gap: 22px; } .profile-split .profile-tabs { flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid #e0e6dc; padding: 0 0 16px; } .profile-split .profile-tabs button { flex: 1 1 40%; width: auto; } }

.password-field { position: relative; display: block; min-width: 0; border: 1px solid #9cac9e; border-radius: 8px; background: #fff; }
.password-field:focus-within { outline: 2px solid #b9803c; outline-offset: 3px; }
.profile-form .password-field input { box-sizing: border-box; width: calc(100% - 48px); border: 0; background: transparent; padding-right: 11px; outline: none; box-shadow: none; }
.profile-form .password-field .password-toggle { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; padding: 8px; border: 0; border-radius: 7px; background: transparent; color: #435e46; cursor: pointer; }
.profile-form .password-field .password-toggle:hover { background: #e9efe4; }
.profile-form .password-field .password-toggle:focus-visible { outline: 2px solid #435e46; outline-offset: 2px; }
Loading