diff --git a/.env.example b/.env.example index ca0a653..bd2e6c1 100644 --- a/.env.example +++ b/.env.example @@ -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 +# APP_PUBLIC_URL=https://din-app.example.no/ diff --git a/server/index.ts b/server/index.ts index 356bffa..83b5928 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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' @@ -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, @@ -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.') @@ -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! @@ -466,20 +479,21 @@ 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') @@ -487,6 +501,44 @@ const schema = createSchema({ 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.') @@ -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) { @@ -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('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(`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 diff --git a/server/passwordMail.ts b/server/passwordMail.ts new file mode 100644 index 0000000..9b164ca --- /dev/null +++ b/server/passwordMail.ts @@ -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 ' + 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((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') +} diff --git a/src/App.tsx b/src/App.tsx index 684780e..894fa0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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)}, []) @@ -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 {setShowLogin(false);setShowHome(true)}} onSkip={()=>{setGuest(true);setShowLogin(false);setShowHome(false);setView('explore')}} /> + if(showLogin&&(!authenticated||window.location.hash.startsWith('#reset-password/')))return {setShowLogin(false);setShowHome(true)}} onSkip={()=>{setGuest(true);setShowLogin(false);setShowHome(false);setView('explore')}} /> if(showHome)return {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 setShowHome(true)} onSkip={()=>{setGuest(true);setView('explore')}} /> diff --git a/src/Profile.css b/src/Profile.css index 4cfb2d9..b57d9fb 100644 --- a/src/Profile.css +++ b/src/Profile.css @@ -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; } diff --git a/src/Profile.tsx b/src/Profile.tsx index 732086b..ac3f964 100644 --- a/src/Profile.tsx +++ b/src/Profile.tsx @@ -1,5 +1,5 @@ import SiteNav from './SiteNav' -import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react' +import { useEffect, useState, type ChangeEvent, type FormEvent, type ComponentProps } from 'react' import './Profile.css' import {TOKEN_KEY} from './graphql' @@ -22,61 +22,77 @@ async function graphQL(query: string, variables: Record, 'type'>) { + const [visible, setVisible] = useState(false) + return +} + export function AuthGate({ onAuthenticated,onSkip,onHome }: { onAuthenticated: () => void;onSkip:()=>void;onHome?:()=>void }) { - const [mode, setMode] = useState<'login'|'register'>('login') + const resetToken = window.location.hash.startsWith('#reset-password/') ? window.location.hash.split('/')[1] : '' + const [mode, setMode] = useState<'login'|'register'|'forgot'|'reset'>(resetToken ? 'reset' : 'login') const [username, setUsername] = useState('') + const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') const [message, setMessage] = useState('') + const [error, setError] = useState(false) const [busy, setBusy] = useState(false) - const [checking, setChecking] = useState(() => Boolean(localStorage.getItem(TOKEN_KEY))) - + const [checking, setChecking] = useState(() => !resetToken && Boolean(localStorage.getItem(TOKEN_KEY))) useEffect(() => { const token = localStorage.getItem(TOKEN_KEY) - if (!token) return + if (!token || resetToken) return const controller = new AbortController() - void graphQL('query($token:String!){me(token:$token){username}}', { token }, AbortSignal.any([controller.signal, AbortSignal.timeout(15000)])) - .then(data => { if (controller.signal.aborted) return; if (data.me) onAuthenticated(); else localStorage.removeItem(TOKEN_KEY) }) - .catch(() => { if (!controller.signal.aborted) setMessage('Kunne ikke kontrollere innloggingen. Kontroller at backend kjører, og prøv igjen.') }) + void graphQL('query($token:String!){me(token:$token){username}}', {token}, AbortSignal.any([controller.signal, AbortSignal.timeout(15000)])) + .then(data => { if (!controller.signal.aborted) { if(data.me) onAuthenticated(); else localStorage.removeItem(TOKEN_KEY) } }) + .catch(() => { if (!controller.signal.aborted) { setError(true); setMessage('Kunne ikke kontrollere innloggingen. Prøv igjen.') } }) .finally(() => { if (!controller.signal.aborted) setChecking(false) }) return () => controller.abort() - }, [onAuthenticated]) - - async function authenticate(event: FormEvent) { - event.preventDefault(); setBusy(true); setMessage('') + }, [onAuthenticated, resetToken]) + function changeMode(next: typeof mode) { setMode(next); setMessage(''); setError(false); setPassword(''); setConfirm('') } + async function submit(event: FormEvent) { + event.preventDefault(); setBusy(true); setMessage(''); setError(false) try { - const mutation = mode === 'login' ? 'login' : 'register' - const data = await graphQL(`mutation($username:String!,$password:String!){${mutation}(username:$username,password:$password){token}}`, { username: username.trim(), password }) - localStorage.setItem(TOKEN_KEY, data[mutation].token) - onAuthenticated() - } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } + if ((mode === 'register' || mode === 'reset') && password !== confirm) throw new Error('Passordene er ikke like.') + if (mode === 'forgot') { + await graphQL('mutation($email:String!){requestPasswordReset(email:$email)}', {email:email.trim()}) + setMessage('Hvis adressen er knyttet til en profil, får den en tilbakestillingslenke. Lenken gjelder i 30 minutter.') + } else if (mode === 'reset') { + await graphQL('mutation($token:String!,$newPassword:String!){resetPassword(token:$token,newPassword:$newPassword)}', {token:resetToken,newPassword:password}) + localStorage.removeItem(TOKEN_KEY); window.history.replaceState(null,'',window.location.pathname + window.location.search + '#profile') + changeMode('login'); setMessage('Passordet er endret. Logg inn med det nye passordet.') + } else { + const query = mode === 'register' ? 'mutation($username:String!,$password:String!,$email:String!){register(username:$username,password:$password,email:$email){token}}' : 'mutation($username:String!,$password:String!){login(username:$username,password:$password){token}}' + const data = await graphQL(query, {username:username.trim(),password,...(mode==='register'?{email:email.trim()}:{})}) + localStorage.setItem(TOKEN_KEY,data[mode].token); onAuthenticated() + } + } catch(reason) { setError(true); setMessage(reason instanceof Error ? reason.message : 'Noe gikk galt. Prøv igjen.') } finally { setBusy(false) } } - return
-
-
-

OPPDAG NORGE TIL FOTS

{checking ? 'Velkommen tilbake.' : mode === 'login' ? 'Logg inn for å utforske.' : 'Opprett din profil.'}

Lagre favoritter og finn registrerte turruter fra hele Norge.

- {checking ?

Kontrollerer innlogging …

: <> -
- - - -
- {message &&

{message}

} - - } - -
-
+
+

OPPDAG NORGE TIL FOTS

{mode==='register'?'Opprett din profil.':mode==='forgot'?'Glemt passord?':mode==='reset'?'Velg nytt passord.':'Velkommen tilbake.'}

+

{mode==='forgot'?'Skriv inn e-postadressen på profilen din.':mode==='reset'?'Velg et nytt passord på minst 10 tegn.':mode==='register'?'Alle feltene er obligatoriske. E-post brukes til å gjenopprette tilgangen til profilen.':'Logg inn for å lagre favoritter og planlegge turer.'}

+ {checking ?

Kontrollerer innlogging …

: <> +
+ {(mode==='login'||mode==='register')&&} + {(mode==='register'||mode==='forgot')&&} + {mode!=='forgot'&&} + {(mode==='register'||mode==='reset')&&} + +
+ {message&&

{message}

} + {mode==='login'&&} + + } +
} export default function Profile({ selected, onSelectRoute, disabled, onLogout, onExplore }: { selected: SelectedRoute | null, onSelectRoute: (route: SelectedRoute) => void, disabled: boolean, onLogout?: () => void, onExplore: () => void }) { const [user, setUser] = useState(null) - const [mode, setMode] = useState<'login'|'register'>('login') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') const [message, setMessage] = useState('') const [busy, setBusy] = useState(false) const [loadingProfile, setLoadingProfile] = useState(true) @@ -121,16 +137,6 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout, o return ()=>controller.abort() }, [token, disabled, onLogout]) - async function authenticate(event: FormEvent) { - event.preventDefault(); setBusy(true); setMessage('') - try { - const mutation = mode === 'login' ? 'login' : 'register' - const data = await graphQL(`mutation($username:String!,$password:String!){${mutation}(username:$username,password:$password){token user{${PROFILE_FIELDS}}}}`, { username, password }) - localStorage.setItem(TOKEN_KEY, data[mutation].token); setUser(data[mutation].user); setPassword('') - } catch (error) { setMessage(error instanceof Error ? error.message : 'Noe gikk galt.') } - finally { setBusy(false) } - } - async function toggleFavorite() { const currentToken = localStorage.getItem(TOKEN_KEY) if (!currentToken || !selected?.id) return @@ -244,15 +250,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout, o if (disabled) return

Min profil

Profil er tilgjengelig når løsningen kjøres med backend og PostgreSQL.

if (loadingProfile || (!user && token)) return

Min profil

{message || 'Henter profilen …'}

- if (!user) return

{mode === 'login' ? 'Logg inn' : 'Opprett profil'}

-
- - - -
- {message &&

{message}

} - -
+ if (!user) return window.location.reload()} onSkip={onExplore} onHome={onExplore} /> const favorite = Boolean(selected?.id && user.favorites.some(item => item.id === selected.id)) const goal = user.goals.find(item => item.month === goalMonth) @@ -275,6 +273,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout, o {([['overview', 'Oversikt'], ['favorites', `Favoritter (${user.favorites.length})`], ['trips', `Mine turer (${user.trips.length})`], ['settings', 'Innstillinger']] as const).map(([key,label]) => )}
+ {!user.email &&

Legg til e-post for å kunne tilbakestille passordet.

} {message &&

{message}

} {tab === 'overview' &&
@@ -313,7 +312,7 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout, o
{user.avatar && } - + - - + + + {passwordMessage &&

{passwordMessage}

}
diff --git a/src/SiteNav.css b/src/SiteNav.css index 6810ca2..994e677 100644 --- a/src/SiteNav.css +++ b/src/SiteNav.css @@ -14,3 +14,8 @@ .site-header .site-dropdown a { display: flex; padding: 11px 14px; border-radius: 7px; color: #233b2d; } .site-header .site-dropdown a:hover { background: #edf2e9; } .site-header button:focus-visible { outline: 2px solid #435e46; outline-offset: 4px; } + +.site-header .site-dropdown .site-logout { display: block; width: 100%; margin-top: 6px; padding: 11px 14px; border: 0; border-top: 1px solid #e0e6dc; border-radius: 0 0 7px 7px; background: transparent; color: #923c35; text-align: left; font: inherit; font-size: 14px; font-weight: 650; cursor: pointer; } +.site-header .site-dropdown .site-logout:hover { background: #f9efec; } +.site-header .site-dropdown .site-logout:disabled { opacity: .6; cursor: wait; } +.site-logout-error { margin: 8px 14px; font-size: 12px; color: #923c35; } diff --git a/src/SiteNav.tsx b/src/SiteNav.tsx index f505d5f..12ae389 100644 --- a/src/SiteNav.tsx +++ b/src/SiteNav.tsx @@ -1,9 +1,12 @@ +import { graphql, TOKEN_KEY } from './graphql' import { useEffect, useRef, useState } from 'react' import './SiteNav.css' type Props = { active?: 'home' | 'explore' | 'mountains' | 'profile'; authenticated: boolean; onHome?: () => void; onExplore?: () => void; onProfile?: () => void } export default function SiteNav({ active, authenticated, onHome, onExplore, onProfile }: Props) { const [open, setOpen] = useState(false) + const [loggingOut, setLoggingOut] = useState(false) + const [logoutError, setLogoutError] = useState('') const menuRef = useRef(null) useEffect(() => { if (!open) return @@ -14,13 +17,22 @@ export default function SiteNav({ active, authenticated, onHome, onExplore, onPr return () => { document.removeEventListener('pointerdown', close); document.removeEventListener('keydown', escape) } }, [open]) const base = import.meta.env.BASE_URL + async function logout() { + setLoggingOut(true); setLogoutError('') + const token = localStorage.getItem(TOKEN_KEY) + try { + if (token) await graphql('mutation($token:String!){logout(token:$token)}', {token}, AbortSignal.timeout(10000)) + localStorage.removeItem(TOKEN_KEY) + window.location.assign(base) + } catch { setLogoutError('Kunne ikke logge ut. Prøv igjen.'); setLoggingOut(false) } + } return
{ event.preventDefault(); onHome() } : undefined}>△ Norwegian Explorer