Update Todos
Skriv migrasjon 019 (app_user.country + bio) Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx Scratch-verifiser hele flyten Be om bekreftelse og rull ut live Scratch-verifisert, alt grønt: Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret. 16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer. Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet. Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account. Klar for ekte teecup_db. Dette krever: Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt). docker compose up -d --build teecup_api teecup_frontend. Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
This commit is contained in:
parent
0dd1ccde8d
commit
79c4406723
6 changed files with 345 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
18
019_profile_country_bio.sql
Normal file
18
019_profile_country_bio.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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<Me | null>(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 (
|
||||
<ProfileOnboarding
|
||||
me={me}
|
||||
onComplete={() => router.replace("/dashboard")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||
|
|
@ -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<string | null>(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 (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-2xl items-center justify-between gap-4 px-5 py-4">
|
||||
<Wordmark compact />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<LogOut aria-hidden="true" className="size-4" />
|
||||
Logg ut
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-8 sm:py-10">
|
||||
<div className="flex flex-col gap-2 pb-6 text-center sm:text-left">
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
||||
Fullfør profilen din
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
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».
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ob-first-name" className="text-sm font-semibold">
|
||||
Fornavn
|
||||
</Label>
|
||||
<Input
|
||||
id="ob-first-name"
|
||||
autoFocus
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ob-last-name" className="text-sm font-semibold">
|
||||
Etternavn
|
||||
</Label>
|
||||
<Input
|
||||
id="ob-last-name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ob-birth-date" className="text-sm font-semibold">
|
||||
Fødselsdato
|
||||
</Label>
|
||||
<Input
|
||||
id="ob-birth-date"
|
||||
type="date"
|
||||
value={birthDate}
|
||||
onChange={(e) => setBirthDate(e.target.value)}
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ob-gender" className="text-sm font-semibold">
|
||||
Kjønn
|
||||
</Label>
|
||||
<select
|
||||
id="ob-gender"
|
||||
value={gender}
|
||||
onChange={(e) => setGender(e.target.value)}
|
||||
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
|
||||
>
|
||||
<option value="">Velg …</option>
|
||||
<option value="f">Dame</option>
|
||||
<option value="m">Herre</option>
|
||||
<option value="x">Annet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ob-hcp" className="text-sm font-semibold">
|
||||
HCP
|
||||
</Label>
|
||||
<Input
|
||||
id="ob-hcp"
|
||||
inputMode="decimal"
|
||||
value={hcp}
|
||||
onChange={(e) => setHcp(e.target.value)}
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ferske spillere har 54 -- la stå om du ikke har en offisiell HCP ennå.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ob-home-club" className="text-sm font-semibold">
|
||||
Hjemmeklubb
|
||||
</Label>
|
||||
<Input
|
||||
id="ob-home-club"
|
||||
value={homeClub}
|
||||
onChange={(e) => setHomeClub(e.target.value)}
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ob-country" className="text-sm font-semibold">
|
||||
Land
|
||||
</Label>
|
||||
<Input
|
||||
id="ob-country"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<Label htmlFor="ob-bio" className="text-sm font-semibold">
|
||||
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
</Label>
|
||||
<textarea
|
||||
id="ob-bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="F.eks. Golf nut, running teeoff.no"
|
||||
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||
|
||||
<Button type="submit" disabled={!valid || saving} className="h-12 w-fit rounded-xl font-semibold">
|
||||
{saving ? "Lagrer …" : "Fortsett"}
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Personlig profil (ADR-031) ---------------------------------------------
|
||||
|
||||
function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||
|
|
@ -170,6 +423,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|||
const [gender, setGender] = useState(me.gender ?? "")
|
||||
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
|
||||
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
|
||||
const [country, setCountry] = useState(me.country ?? "")
|
||||
const [bio, setBio] = useState(me.bio ?? "")
|
||||
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
|
||||
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
|
@ -195,6 +450,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|||
gender: gender === "" ? null : gender,
|
||||
handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")),
|
||||
home_club: homeClub.trim() === "" ? null : homeClub.trim(),
|
||||
country: country.trim() === "" ? null : country.trim(),
|
||||
bio: bio.trim() === "" ? null : bio.trim(),
|
||||
mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(),
|
||||
mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(),
|
||||
}),
|
||||
|
|
@ -371,6 +628,30 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="country" className="text-sm font-semibold">
|
||||
Land
|
||||
</Label>
|
||||
<Input
|
||||
id="country"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<Label htmlFor="bio" className="text-sm font-semibold">
|
||||
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
</Label>
|
||||
<textarea
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="F.eks. Golf nut, running teeoff.no"
|
||||
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<Label htmlFor="mobile-number" className="text-sm font-semibold">
|
||||
Mobil
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ type Me = {
|
|||
email: string
|
||||
display_name: string
|
||||
preferred_locale: string
|
||||
profile_complete: boolean
|
||||
organizations: MyOrg[]
|
||||
my_tournaments: MyTournament[]
|
||||
}
|
||||
|
|
@ -104,6 +105,14 @@ export function Dashboard() {
|
|||
}
|
||||
const data: Me = await res.json()
|
||||
if (cancelled) return
|
||||
// Obligatorisk profil-fullføring (2026-07-22): dekker enhver vei
|
||||
// INN til dashbordet (magic-link/passord/2FA-verifisering lander
|
||||
// her direkte) -- /account viser selv fullførings-skjemaet så
|
||||
// lenge profil_complete er false.
|
||||
if (!data.profile_complete) {
|
||||
router.replace("/account")
|
||||
return
|
||||
}
|
||||
setMe(data)
|
||||
setActiveOrgId(data.organizations[0]?.organization_id ?? null)
|
||||
} catch {
|
||||
|
|
|
|||
Loading…
Reference in a new issue