2026-07-18 06:46:09 +02:00
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
import type React from "react"
|
|
|
|
|
import { useEffect, useState } from "react"
|
|
|
|
|
import { useRouter } from "next/navigation"
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
import Link from "next/link"
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
import { Building2, Calendar, ChevronRight, ChevronsUpDown, LogOut, Plus, Trophy, Users, UserCircle, X, Check } from "lucide-react"
|
2026-07-18 06:46:09 +02:00
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
|
import { Input } from "@/components/ui/input"
|
|
|
|
|
import { Label } from "@/components/ui/label"
|
|
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu"
|
|
|
|
|
import { Wordmark } from "@/components/wordmark"
|
|
|
|
|
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge"
|
|
|
|
|
import { cn } from "@/lib/utils"
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
|
|
|
type MyOrg = { organization_id: string; name: string; role: string }
|
|
|
|
|
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
// ADR-031: "Mine runder" -- turneringer brukeren er SPILLER i (rostret på et
|
|
|
|
|
// lag), på tvers av organisasjoner, uavhengig av organisasjonsmedlemskap.
|
|
|
|
|
type MyTournament = {
|
|
|
|
|
organization_id: string
|
|
|
|
|
organization_name: string
|
|
|
|
|
tournament_id: string
|
|
|
|
|
tournament_name: string
|
|
|
|
|
status: TournamentStatus
|
|
|
|
|
team_id: string
|
|
|
|
|
team_name: string
|
|
|
|
|
team_color: string | null
|
|
|
|
|
next_session_at: string | null
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 06:46:09 +02:00
|
|
|
type Me = {
|
|
|
|
|
id: string
|
|
|
|
|
email: string
|
|
|
|
|
display_name: string
|
|
|
|
|
preferred_locale: string
|
|
|
|
|
organizations: MyOrg[]
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
my_tournaments: MyTournament[]
|
2026-07-18 06:46:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ApiTournament = {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
status: TournamentStatus
|
|
|
|
|
start_date: string | null
|
|
|
|
|
end_date: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toTournament(t: ApiTournament): Tournament {
|
|
|
|
|
return {
|
|
|
|
|
id: t.id,
|
|
|
|
|
name: t.name,
|
|
|
|
|
status: t.status,
|
|
|
|
|
startDate: t.start_date ?? undefined,
|
|
|
|
|
endDate: t.end_date ?? undefined,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Component -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function Dashboard() {
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
const [me, setMe] = useState<Me | null>(null)
|
|
|
|
|
const [loadingMe, setLoadingMe] = useState(true)
|
|
|
|
|
const [activeOrgId, setActiveOrgId] = useState<string | null>(null)
|
|
|
|
|
const [tournaments, setTournaments] = useState<Tournament[]>([])
|
|
|
|
|
const [loadingTournaments, setLoadingTournaments] = useState(false)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
// Hent innlogget bruker + organisasjonsmedlemskap. Ingen gyldig sesjon ->
|
|
|
|
|
// tilbake til innloggingssiden (denne siden krever auth).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false
|
|
|
|
|
async function loadMe() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch("/auth/me", { credentials: "include" })
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
router.replace("/")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const data: Me = await res.json()
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
setMe(data)
|
|
|
|
|
setActiveOrgId(data.organizations[0]?.organization_id ?? null)
|
|
|
|
|
} catch {
|
|
|
|
|
if (!cancelled) router.replace("/")
|
|
|
|
|
} finally {
|
|
|
|
|
if (!cancelled) setLoadingMe(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
void loadMe()
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
|
|
|
|
}
|
|
|
|
|
}, [router])
|
|
|
|
|
|
|
|
|
|
// Turneringer hentes per valgt organisasjon, ikke i /auth/me-kallet.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!activeOrgId) {
|
|
|
|
|
setTournaments([])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let cancelled = false
|
|
|
|
|
async function loadTournaments() {
|
|
|
|
|
setLoadingTournaments(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/orgs/${activeOrgId}/tournaments`, { credentials: "include" })
|
|
|
|
|
if (!res.ok) throw new Error(`tournaments: ${res.status}`)
|
|
|
|
|
const data: ApiTournament[] = await res.json()
|
|
|
|
|
if (!cancelled) setTournaments(data.map(toTournament))
|
|
|
|
|
} catch {
|
|
|
|
|
if (!cancelled) setError("Klarte ikke å hente turneringer. Prøv igjen om litt.")
|
|
|
|
|
} finally {
|
|
|
|
|
if (!cancelled) setLoadingTournaments(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
void loadTournaments()
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
|
|
|
|
}
|
|
|
|
|
}, [activeOrgId])
|
|
|
|
|
|
|
|
|
|
async function handleCreateOrg(name: string) {
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch("/orgs", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: JSON.stringify({ name }),
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) throw new Error(`orgs: ${res.status}`)
|
|
|
|
|
const org: { id: string; name: string; role: string } = await res.json()
|
|
|
|
|
setMe((prev) =>
|
|
|
|
|
prev
|
|
|
|
|
? {
|
|
|
|
|
...prev,
|
|
|
|
|
organizations: [
|
|
|
|
|
...prev.organizations,
|
|
|
|
|
{ organization_id: org.id, name: org.name, role: org.role },
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
: prev,
|
|
|
|
|
)
|
|
|
|
|
setActiveOrgId(org.id)
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å opprette organisasjonen. Prøv igjen.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleCreateTournament(name: string) {
|
|
|
|
|
if (!activeOrgId) return
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/orgs/${activeOrgId}/tournaments`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: JSON.stringify({ name }),
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) throw new Error(`create tournament: ${res.status}`)
|
|
|
|
|
const created: ApiTournament = await res.json()
|
|
|
|
|
setTournaments((prev) => [toTournament(created), ...prev])
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å opprette turneringen. Prøv igjen.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleLogout() {
|
|
|
|
|
try {
|
|
|
|
|
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
|
|
|
|
} finally {
|
|
|
|
|
router.replace("/")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loadingMe) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!me) return null // router.replace("/") er allerede utløst
|
|
|
|
|
|
|
|
|
|
const activeOrg = me.organizations.find((o) => o.organization_id === activeOrgId) ?? null
|
|
|
|
|
const hasOrg = me.organizations.length > 0 && activeOrg !== null
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
const hasMyTournaments = me.my_tournaments.length > 0
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
|
|
|
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-3xl items-center justify-between gap-4 px-5 py-4">
|
|
|
|
|
<Wordmark compact />
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Link
|
|
|
|
|
href="/account"
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
<UserCircle aria-hidden="true" className="size-4" />
|
|
|
|
|
Konto
|
|
|
|
|
</Link>
|
|
|
|
|
<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>
|
2026-07-18 06:46:09 +02:00
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
|
|
|
|
{error && (
|
|
|
|
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
|
|
|
|
{error}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
<div className="flex flex-col gap-8">
|
|
|
|
|
{hasMyTournaments && <MyToursSection tournaments={me.my_tournaments} />}
|
|
|
|
|
|
|
|
|
|
{hasOrg && activeOrg ? (
|
|
|
|
|
<OrganizationView
|
|
|
|
|
org={activeOrg}
|
|
|
|
|
orgs={me.organizations}
|
|
|
|
|
tournaments={tournaments}
|
|
|
|
|
loadingTournaments={loadingTournaments}
|
|
|
|
|
onSelectOrg={setActiveOrgId}
|
|
|
|
|
onCreateTournament={handleCreateTournament}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<CreateOrganizationState onCreate={handleCreateOrg} compact={hasMyTournaments} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-07-18 06:46:09 +02:00
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
// --- "Mine runder" (ADR-031) -----------------------------------------------
|
|
|
|
|
// Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap.
|
|
|
|
|
// Lenker til den offentlige turnering-siden (fungerer for enhver deltaker,
|
|
|
|
|
// se ADR-031-utvidelsen av check_visibility) -- IKKE til lag-chat/
|
|
|
|
|
// scorekort ennå, som fortsatt krever organisasjonsmedlemskap (kjent,
|
|
|
|
|
// notert begrensning, se FEATURE_BACKLOG.md).
|
|
|
|
|
|
|
|
|
|
function MyToursSection({ tournaments }: { tournaments: MyTournament[] }) {
|
|
|
|
|
return (
|
|
|
|
|
<section className="flex flex-col gap-3" aria-label="Mine runder">
|
|
|
|
|
<h2 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mine runder</h2>
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
{tournaments.map((t) => (
|
|
|
|
|
<Link
|
|
|
|
|
key={`${t.organization_id}-${t.tournament_id}`}
|
|
|
|
|
href={`/t/${t.tournament_id}`}
|
|
|
|
|
className="group flex items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="size-3 shrink-0 rounded-full"
|
|
|
|
|
style={{ backgroundColor: t.team_color ?? "#64748b" }}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
|
|
|
<h3 className="truncate text-base font-bold text-foreground">{t.tournament_name}</h3>
|
|
|
|
|
<TournamentStatusBadge status={t.status} />
|
|
|
|
|
</div>
|
|
|
|
|
<span className="truncate text-sm text-muted-foreground">
|
|
|
|
|
{t.organization_name} · {t.team_name}
|
|
|
|
|
</span>
|
|
|
|
|
{t.next_session_at && (
|
|
|
|
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
|
|
|
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
<span className="tabular-nums">{formatNextSession(t.next_session_at)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<ChevronRight
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
|
|
|
|
/>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatNextSession(iso: string) {
|
|
|
|
|
const date = new Date(iso)
|
|
|
|
|
if (Number.isNaN(date.getTime())) return iso
|
|
|
|
|
return new Intl.DateTimeFormat("no-NO", {
|
|
|
|
|
weekday: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
month: "short",
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
}).format(date)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 06:46:09 +02:00
|
|
|
// --- State A: no organization ---------------------------------------------
|
|
|
|
|
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
function CreateOrganizationState({
|
|
|
|
|
onCreate,
|
|
|
|
|
compact = false,
|
|
|
|
|
}: {
|
|
|
|
|
onCreate: (name: string) => void
|
|
|
|
|
compact?: boolean
|
|
|
|
|
}) {
|
2026-07-18 06:46:09 +02:00
|
|
|
const [name, setName] = useState("")
|
|
|
|
|
const valid = name.trim().length >= 2
|
|
|
|
|
|
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (!valid) return
|
|
|
|
|
onCreate(name.trim())
|
|
|
|
|
setName("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"mx-auto flex max-w-md flex-col items-center text-center",
|
|
|
|
|
compact ? "gap-4" : "gap-6 pt-6 sm:pt-12",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{!compact && (
|
|
|
|
|
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
|
|
|
|
<Building2 aria-hidden="true" className="size-8 text-primary" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
<h1 className={cn("font-extrabold tracking-tight text-foreground text-balance", compact ? "text-lg" : "text-2xl")}>
|
|
|
|
|
{compact ? "Opprett din egen organisasjon" : "Du har ingen organisasjon ennå"}
|
2026-07-18 06:46:09 +02:00
|
|
|
</h1>
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
{compact
|
|
|
|
|
? "Skal du selv arrangere turneringer? En organisasjon er golfklubben eller bedriften som står bak."
|
|
|
|
|
: "En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for å komme i gang."}
|
2026-07-18 06:46:09 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<form
|
|
|
|
|
onSubmit={handleSubmit}
|
|
|
|
|
className="flex w-full flex-col gap-4 rounded-3xl border border-border bg-card p-6 text-left shadow-lg shadow-black/5"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Label htmlFor="org-name" className="text-sm font-semibold">
|
|
|
|
|
Navn på organisasjon
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="org-name"
|
|
|
|
|
autoFocus
|
|
|
|
|
placeholder="F.eks. Oslo Golfklubb"
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
|
|
|
|
className="h-14 rounded-2xl text-base"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={!valid}
|
|
|
|
|
className="h-14 rounded-2xl text-base font-bold shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
Opprett organisasjon
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- State B: has organization(s) -----------------------------------------
|
|
|
|
|
|
|
|
|
|
function OrganizationView({
|
|
|
|
|
org,
|
|
|
|
|
orgs,
|
|
|
|
|
tournaments,
|
|
|
|
|
loadingTournaments,
|
|
|
|
|
onSelectOrg,
|
|
|
|
|
onCreateTournament,
|
|
|
|
|
}: {
|
|
|
|
|
org: MyOrg
|
|
|
|
|
orgs: MyOrg[]
|
|
|
|
|
tournaments: Tournament[]
|
|
|
|
|
loadingTournaments: boolean
|
|
|
|
|
onSelectOrg: (id: string) => void
|
|
|
|
|
onCreateTournament: (name: string) => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-6">
|
|
|
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
|
|
|
{orgs.length > 1 ? (
|
|
|
|
|
<OrganizationSwitcher org={org} orgs={orgs} onSelectOrg={onSelectOrg} />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
|
|
|
|
<Building2 aria-hidden="true" className="size-5 text-primary" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Organisasjon
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-lg font-extrabold tracking-tight text-foreground">
|
|
|
|
|
{org.name}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Link
|
2026-07-21 09:03:52 +02:00
|
|
|
href={`/organizations/${org.organization_id}/members?name=${encodeURIComponent(org.name)}`}
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
className="inline-flex h-11 items-center gap-1.5 rounded-2xl border border-border bg-card px-4 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
|
|
|
|
|
>
|
|
|
|
|
<Users aria-hidden="true" className="size-4" />
|
|
|
|
|
Medlemmer
|
|
|
|
|
</Link>
|
|
|
|
|
<NewTournamentControl onCreate={onCreateTournament} />
|
|
|
|
|
</div>
|
2026-07-18 06:46:09 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<section className="flex flex-col gap-3" aria-label="Turneringer">
|
|
|
|
|
{loadingTournaments ? (
|
|
|
|
|
<div className="flex justify-center py-12">
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : tournaments.length > 0 ? (
|
Lag/roster-skjermen er live på https://teecup.teeoff.no (klikk et turneringskort på dashbordet). Oppsummert:
Bygget:
/tournaments/[id] — to lag side ved side, hvert med egen fargevelger, spillerliste og en type-ahead for "legg til spiller" (søker i organisasjonens spillerpool, tilbyr inline "opprett ny spiller" hvis ingen treff, markerer spillere allerede rostret på det andre laget som utilgjengelige).
Fant et reelt hull før integrering, ikke etter: V0-skjermen bygger inn "fjern spiller" og "gjør til kaptein" — backend hadde bare GET/POST på roster, ingen DELETE/PATCH. Spurte deg, du sa bygg dem nå — lagt til og scratch-verifisert (PATCH setter kaptein riktig, DELETE gir 204 og er idempotent, test_isolation.sql fortsatt 12/12).
Navigasjon fra dashbordet er kablet opp (turneringskort er nå en ekte lenke).
Bevisst forenkling notert i backloggen: ingen håndheving av "kun én kaptein per lag" ennå — flere kan merkes samtidig. Hører sammen med det uavklarte brukerroller-punktet, løses ikke isolert her.
Verifisert: ekte typesjekket build (5 ruter), begge containere redeployet (backend hadde nye endepunkter), teeoff.no upåvirket. Selve skrive-flyten (opprett lag/spiller) er ikke testet med ekte data — samme som sist, venter på deg. Du har allerede "De Gamle er Eldst" liggende i "Tjøme Gents" — vil du prøve å sette opp de to lagene der?
2026-07-18 07:15:38 +02:00
|
|
|
tournaments.map((t) => (
|
|
|
|
|
<TournamentCard key={t.id} tournament={t} orgId={org.organization_id} />
|
|
|
|
|
))
|
2026-07-18 06:46:09 +02:00
|
|
|
) : (
|
|
|
|
|
<EmptyTournamentState orgName={org.name} />
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function OrganizationSwitcher({
|
|
|
|
|
org,
|
|
|
|
|
orgs,
|
|
|
|
|
onSelectOrg,
|
|
|
|
|
}: {
|
|
|
|
|
org: MyOrg
|
|
|
|
|
orgs: MyOrg[]
|
|
|
|
|
onSelectOrg: (id: string) => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger className="flex items-center gap-3 rounded-2xl border border-border bg-card px-3 py-2.5 text-left shadow-sm shadow-black/5 transition-colors hover:bg-accent/50">
|
|
|
|
|
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
|
|
|
|
<Building2 aria-hidden="true" className="size-5 text-primary" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Organisasjon
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-lg font-extrabold tracking-tight text-foreground">
|
|
|
|
|
{org.name}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<ChevronsUpDown aria-hidden="true" className="ml-1 size-4 text-muted-foreground" />
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="start" className="w-64 rounded-2xl p-1.5">
|
|
|
|
|
{orgs.map((o) => (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={o.organization_id}
|
|
|
|
|
onClick={() => onSelectOrg(o.organization_id)}
|
|
|
|
|
className="flex cursor-pointer items-center justify-between gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
|
|
|
|
>
|
|
|
|
|
{o.name}
|
|
|
|
|
{o.organization_id === org.organization_id && (
|
|
|
|
|
<Check aria-hidden="true" className="size-4 text-primary" />
|
|
|
|
|
)}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
))}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function NewTournamentControl({ onCreate }: { onCreate: (name: string) => void }) {
|
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
|
const [name, setName] = useState("")
|
|
|
|
|
const valid = name.trim().length >= 2
|
|
|
|
|
|
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (!valid) return
|
|
|
|
|
onCreate(name.trim())
|
|
|
|
|
setName("")
|
|
|
|
|
setOpen(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOpen(true)}
|
|
|
|
|
className="h-12 shrink-0 rounded-2xl text-base font-bold shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
<Plus aria-hidden="true" className="size-5" />
|
|
|
|
|
Ny turnering
|
|
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<form
|
|
|
|
|
onSubmit={handleSubmit}
|
|
|
|
|
className="flex w-full items-center gap-2 rounded-2xl border border-border bg-card p-2 shadow-sm shadow-black/5 sm:w-auto"
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
autoFocus
|
|
|
|
|
placeholder="Navn på turnering"
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Escape") setOpen(false)
|
|
|
|
|
}}
|
|
|
|
|
className="h-10 min-w-0 flex-1 rounded-xl text-base sm:w-56"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={!valid}
|
|
|
|
|
size="icon"
|
|
|
|
|
className="size-10 shrink-0 rounded-xl"
|
|
|
|
|
aria-label="Opprett turnering"
|
|
|
|
|
>
|
|
|
|
|
<Check aria-hidden="true" className="size-5" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
className="size-10 shrink-0 rounded-xl text-muted-foreground"
|
|
|
|
|
aria-label="Avbryt"
|
|
|
|
|
>
|
|
|
|
|
<X aria-hidden="true" className="size-5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function EmptyTournamentState({ orgName }: { orgName: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
|
|
|
|
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
|
|
|
|
<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<h2 className="text-base font-bold text-foreground text-balance">
|
|
|
|
|
Ingen turneringer ennå i {orgName}
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
|
|
|
Trykk på «Ny turnering» for å opprette den første.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|