diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1a042d8..fd9203c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -341,7 +341,8 @@ "Bash(python3 -m py_compile app/routers/auth.py app/routers/scoring.py app/routers/matches.py app/routers/tournaments.py app/routers/courses.py app/routers/messaging.py app/routers/registration.py app/team_authz.py)", "Bash(rm -f /opt/requirements.txt)", "Bash(python3 -m py_compile app/routers/*.py app/*.py)", - "Bash(python3 -m py_compile app/routers/auth.py app/email.py)" + "Bash(python3 -m py_compile app/routers/auth.py app/email.py)", + "Bash(sort -t_ -k1 -n)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/019_profile_country_bio.sql b/019_profile_country_bio.sql new file mode 100644 index 0000000..ac16643 --- /dev/null +++ b/019_profile_country_bio.sql @@ -0,0 +1,18 @@ +-- ===================================================================== +-- TeeCup — land + beskrivelse på personlig profil (migrasjon 019) +-- ===================================================================== +-- Oppfølging av ADR-031 sin personlige profil (migrasjon 015): brukeren +-- ba om et obligatorisk "land"-felt og et valgfritt "beskrivelse"-felt +-- som del av den obligatoriske profil-fullføringen ved første innlogging +-- (se CLAUDE.md-status). Samme mønster som resten av `app_user`-profilen +-- -- nullable kolonne, "obligatorisk" håndheves i app-laget +-- (`profile_complete` i /auth/me), ikke som en DB NOT NULL-constraint +-- (unngår å bryte eksisterende kontoer som ennå ikke har fylt inn feltet). +-- `country` er fri tekst, samme design som det allerede eksisterende +-- org-scopede `player.country` (migrasjon 007) -- ingen fast landliste. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE app_user ADD COLUMN country text; +ALTER TABLE app_user ADD COLUMN bio text; diff --git a/app/routers/auth.py b/app/routers/auth.py index 2edd491..3751776 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -632,9 +632,17 @@ class Me(BaseModel): gender: str | None handicap_index: float | None home_club: str | None + country: str | None + bio: str | None mobile_country_code: str | None mobile_number: str | None avatar_url: str | None + # Obligatorisk-felt-sjekk (2026-07-22): true når alle obligatoriske + # profilfelt er utfylt (fornavn/etternavn/fødselsdato/kjønn/hcp/ + # hjemmeklubb/land) -- beskrivelse og bilde er bevisst IKKE med, de er + # valgfrie. Frontend bruker denne til å tvinge en ny/ufullstendig + # bruker gjennom profil-fullføring FØR resten av appen vises. + profile_complete: bool my_tournaments: list[MyTournament] secondary_emails: list[SecondaryEmailOut] @@ -662,7 +670,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: SELECT id::text AS id, email::text AS email, display_name, preferred_locale, (password_hash IS NOT NULL) AS has_password, two_factor_method, first_name, last_name, birth_date, gender, - handicap_index::float AS handicap_index, home_club, + handicap_index::float AS handicap_index, home_club, country, bio, mobile_country_code, mobile_number, avatar_key FROM app_user WHERE id = $1 """, @@ -724,6 +732,11 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: MyTournament(organization_id=org_id, organization_name=org_name, **dict(r)) ) + profile_complete = all( + user_row[field] is not None + for field in ("first_name", "last_name", "birth_date", "gender", "handicap_index", "home_club", "country") + ) + return Me( id=user_row["id"], email=user_row["email"], @@ -738,9 +751,12 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: gender=user_row["gender"], handicap_index=user_row["handicap_index"], home_club=user_row["home_club"], + country=user_row["country"], + bio=user_row["bio"], mobile_country_code=user_row["mobile_country_code"], mobile_number=user_row["mobile_number"], avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None, + profile_complete=profile_complete, my_tournaments=my_tournaments, secondary_emails=[SecondaryEmailOut(id=r["id"], email=r["email"]) for r in secondary_email_rows], ) @@ -757,8 +773,14 @@ class ProfileUpdate(BaseModel): last_name: str | None = Field(default=None, max_length=100) birth_date: date | None = None gender: str | None = Field(default=None, pattern="^[mfx]$") - handicap_index: float | None = None + # WHS-maksimum er 54.0 -- en fersk spiller uten offisiell HCP ennå skal + # bruke nettopp dette som utgangspunkt (frontend forhåndsutfyller + # skjemaet med 54), ikke la feltet stå tomt. Nedre grense er romslig + # nok til å dekke reelle plusshandicap. + handicap_index: float | None = Field(default=None, ge=-10, le=54) home_club: str | None = Field(default=None, max_length=200) + country: str | None = Field(default=None, max_length=100) + bio: str | None = Field(default=None, max_length=500) # Landsnummer som eget felt (f.eks. "+47"), ikke sammensatt med selve # nummeret -- lar frontend tilby en egen landsnummer-velger uten å # måtte parse/splitte en fritekststreng i etterkant. diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index c5e7c35..9261393 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -22,6 +22,7 @@ export default async function Page() { const cookieStore = await cookies() const session = cookieStore.get(SESSION_COOKIE_NAME) let authenticated = false + let profileComplete = true if (session) { try { const res = await fetch(`${API_ORIGIN}/auth/me`, { @@ -29,13 +30,21 @@ export default async function Page() { cache: "no-store", }) authenticated = res.ok + if (res.ok) { + const data = await res.json() + profileComplete = Boolean(data.profile_complete) + } } catch { // API utilgjengelig -- vis innloggingsskjemaet i stedet for å henge. authenticated = false } } if (authenticated) { - redirect("/dashboard") + // Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som + // ennå ikke har fylt ut de obligatoriske profilfeltene sendes til + // /account (som selv viser en fokusert fullførings-visning) i stedet + // for dashbordet -- se AccountSettings sin profile_complete-gren. + redirect(profileComplete ? "/dashboard" : "/account") } return ( diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index 0f8020f..f10c184 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -8,10 +8,25 @@ import type React from "react" import { useEffect, useRef, useState } from "react" import Link from "next/link" -import { ArrowLeft, Camera, KeyRound, Lock, Mail, Plus, ShieldCheck, ShieldOff, Trash2, User, X } from "lucide-react" +import { useRouter } from "next/navigation" +import { + ArrowLeft, + Camera, + KeyRound, + Lock, + LogOut, + Mail, + Plus, + ShieldCheck, + ShieldOff, + Trash2, + User, + X, +} from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { Wordmark } from "@/components/wordmark" import { TwoFactorSetupForm } from "@/components/two-factor-flow" // ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de @@ -28,13 +43,19 @@ type Me = { gender: "m" | "f" | "x" | null handicap_index: number | null home_club: string | null + country: string | null + bio: string | null mobile_country_code: string | null mobile_number: string | null avatar_url: string | null + // 2026-07-22: alle obligatoriske profilfelt utfylt (bilde/beskrivelse + // teller ikke). Se AccountSettings sin bruk under. + profile_complete: boolean secondary_emails: { id: string; email: string }[] } export function AccountSettings() { + const router = useRouter() const [me, setMe] = useState(null) const [loading, setLoading] = useState(true) const [settingUp2fa, setSettingUp2fa] = useState(false) @@ -71,6 +92,19 @@ export function AccountSettings() { if (!me) return null + // Obligatorisk profil-fullføring (2026-07-22): en ny/ufullstendig konto + // ser KUN dette skjemaet -- ingen tilgang til resten av kontosidene eller + // dashbordet før alle obligatoriske felt er utfylt. Se page.tsx og + // Dashboard for de to inngangspunktene som sender brukeren hit. + if (!me.profile_complete) { + return ( + router.replace("/dashboard")} + /> + ) + } + return (
@@ -161,6 +195,225 @@ export function AccountSettings() { ) } +// --- Obligatorisk profil-fullføring (2026-07-22) ---------------------------- +// Vises i STEDET for resten av appen (dashbord, øvrige kontoseksjoner) helt +// til alle obligatoriske felt er utfylt -- se AccountSettings sin +// profile_complete-gren. Kun bilde og beskrivelse er valgfrie her. + +function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void }) { + const router = useRouter() + const [firstName, setFirstName] = useState(me.first_name ?? "") + const [lastName, setLastName] = useState(me.last_name ?? "") + const [birthDate, setBirthDate] = useState(me.birth_date ?? "") + const [gender, setGender] = useState(me.gender ?? "") + // WHS-maksimum (54) er riktig utgangspunkt for en spiller uten offisiell + // HCP ennå -- forhåndsutfylt, ikke tomt, jf. brukerens eksplisitte ønske. + const [hcp, setHcp] = useState(me.handicap_index === null ? "54" : String(me.handicap_index)) + const [homeClub, setHomeClub] = useState(me.home_club ?? "") + const [country, setCountry] = useState(me.country ?? "") + const [bio, setBio] = useState(me.bio ?? "") + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const valid = + firstName.trim() !== "" && + lastName.trim() !== "" && + birthDate !== "" && + gender !== "" && + hcp.trim() !== "" && + homeClub.trim() !== "" && + country.trim() !== "" + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!valid || saving) return + setSaving(true) + setError(null) + try { + const res = await fetch("/auth/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + first_name: firstName.trim(), + last_name: lastName.trim(), + birth_date: birthDate, + gender, + handicap_index: Number(hcp.replace(",", ".")), + home_club: homeClub.trim(), + country: country.trim(), + bio: bio.trim() === "" ? null : bio.trim(), + }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Klarte ikke å lagre profilen.") + } + const updated: Me = await res.json() + if (updated.profile_complete) { + onComplete() + } + } catch (err) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + } finally { + setSaving(false) + } + } + + async function handleLogout() { + try { + await fetch("/auth/logout", { method: "POST", credentials: "include" }) + } finally { + router.replace("/") + } + } + + return ( +
+
+
+ + +
+
+ +
+
+

+ Fullfør profilen din +

+

+ Før du kan komme videre trenger vi noen opplysninger om deg. Alt utenom beskrivelse + er obligatorisk. Du kan redigere alt dette senere under «Konto». +

+
+ +
+
+
+ + setFirstName(e.target.value)} + className="h-11 rounded-xl" + /> +
+
+ + setLastName(e.target.value)} + className="h-11 rounded-xl" + /> +
+
+ + setBirthDate(e.target.value)} + className="h-11 rounded-xl" + /> +
+
+ + +
+
+ + setHcp(e.target.value)} + className="h-11 rounded-xl" + /> +

+ Ferske spillere har 54 -- la stå om du ikke har en offisiell HCP ennå. +

+
+
+ + setHomeClub(e.target.value)} + className="h-11 rounded-xl" + /> +
+
+ + setCountry(e.target.value)} + className="h-11 rounded-xl" + /> +
+
+ +