Files
accounted/components/settings/TeamPanel.tsx
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

545 lines
20 KiB
TypeScript

'use client'
import { useLocale, useTranslations } from 'next-intl'
import { useState, useEffect, useCallback } from 'react'
import { Loader2, Mail, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { AttnLine } from '@/components/ui/attn-line'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsInput,
} from '@/components/settings/SettingsRows'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { parseTeamMembersPayload } from '@/components/settings/members-payload'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { cn, formatDateLong } from '@/lib/utils'
// Role dropdowns use the Radix Select for its styled popup (the native
// <select> list cannot be styled and clashes with the panel), but the
// TRIGGER keeps the flat quiet SettingsSelect look: borderless, dashed
// underline on hover, no box. Shared by the member rows and the invite form.
const ROLE_TRIGGER_CLASS =
'h-auto w-auto shrink-0 gap-1.5 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm ' +
'hover:border-border focus:border-solid focus:border-foreground/50 focus:ring-0'
interface TeamMember {
id: string
user_id: string
email: string
role: 'owner' | 'admin' | 'member'
joined_at: string | null
is_current_user: boolean
}
interface TeamInvitation {
id: string
email: string
role: string
status: string
created_at: string
expires_at: string
}
/**
* The shareable accept link from the latest invite create/re-send response.
* Raw tokens are never stored server-side (only their hash), so the link
* exists exactly once: here, until the next navigation. It is kept visible
* so a failed mail send never dead-ends the inviter (the Arbore case: the
* invitation quietly waits for a mail that never arrives).
*/
interface ShareableInvite {
email: string
url: string
sent: boolean
}
/**
* Team roster panel. Read-only for personal teams (exactly the pre-WL-08
* rendering); on a byrå team where the caller may manage members
* (canInvite = owner/admin), the same flat rows gain a role select and a
* remove action (confirm-up-front), plus the pending-invitations list with
* revoke and an inline invite form (WL-08 invite unfreeze, gap 2).
*/
export function TeamPanel() {
const t = useTranslations('settings_team_panel')
const errorLocale = useLocale() as ErrorLocale
const { toast } = useToast()
// null = the roster is not known: still loading, or the read failed
// (loadError). A failed read must never render an apparently member-less
// team; the empty look is reserved for a confirmed empty read.
const [members, setMembers] = useState<TeamMember[] | null>(null)
const [invitations, setInvitations] = useState<TeamInvitation[]>([])
const [teamName, setTeamName] = useState('')
const [teamId, setTeamId] = useState<string | null>(null)
const [isOwner, setIsOwner] = useState(false)
const [canManage, setCanManage] = useState(false)
// detail === null: transient, so the line carries a retry. A detail sentence
// means the user has to act (an expired session) and a retry cannot help.
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState<string>('member')
const [isSending, setIsSending] = useState(false)
const [changingRoleId, setChangingRoleId] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [resendingId, setResendingId] = useState<string | null>(null)
const [shareInvite, setShareInvite] = useState<ShareableInvite | null>(null)
// Confirm-up-front (UI convention 10): removal opens a dialog describing
// the outcome; the DELETE only fires from its confirm button.
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(null)
const roleLabel = (role: string) => {
switch (role) {
case 'owner': return t('role_owner')
case 'admin': return t('role_admin')
case 'member': return t('role_member')
default: return role
}
}
const errorTitle = (body: unknown, fallback: string): string => {
const message = (body as { error?: unknown } | null)?.error
return typeof message === 'string' && message.length > 0 ? message : fallback
}
const fetchMembers = useCallback(async (opts?: { cancelled?: () => boolean }) => {
const isCancelled = opts?.cancelled ?? (() => false)
setLoadError(null)
try {
const res = await fetch('/api/team/members')
if (!res.ok) {
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
// getErrorMessage falls back to the status map.
const body = await res.json().catch(() => null)
if (isCancelled()) return
const sessionGone = res.status === 401 || res.status === 403
setMembers(null)
setLoadError({
detail: sessionGone
? getErrorMessage(body, { statusCode: res.status, locale: errorLocale })
: null,
})
return
}
// A 200 whose body will not parse throws into the catch below; a 200
// without the roster list is a failed read too. Neither may become a
// fabricated empty member list.
const parsed = parseTeamMembersPayload<TeamMember, TeamInvitation>(await res.json())
if (isCancelled()) return
if (parsed === null) {
setMembers(null)
setLoadError({ detail: null })
return
}
setMembers(parsed.members)
setInvitations(parsed.invitations)
setTeamId(parsed.teamId)
setIsOwner(parsed.isOwner)
// Management affordances follow the API's own gate: byrå owner/admin.
setCanManage(parsed.teamKind === 'byra' && parsed.canInvite)
if (parsed.teamName) setTeamName(parsed.teamName)
} catch {
if (!isCancelled()) {
setMembers(null)
setLoadError({ detail: null })
}
}
}, [errorLocale])
useEffect(() => {
let cancelled = false
void fetchMembers({ cancelled: () => cancelled })
return () => {
cancelled = true
}
}, [fetchMembers, reloadKey])
const handleRoleChange = async (member: TeamMember, newRole: string) => {
setChangingRoleId(member.id)
try {
const res = await fetch(`/api/team/members/${member.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('role_change_failed')), variant: 'destructive' })
return
}
toast({ title: t('role_changed_toast') })
} catch {
toast({ title: t('role_change_failed'), variant: 'destructive' })
} finally {
setChangingRoleId(null)
void fetchMembers()
}
}
const handleRemove = async (member: TeamMember) => {
try {
const res = await fetch(`/api/team/members/${member.id}`, { method: 'DELETE' })
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('remove_failed')), variant: 'destructive' })
return
}
toast({ title: t('removed_toast') })
} catch {
toast({ title: t('remove_failed'), variant: 'destructive' })
} finally {
void fetchMembers()
}
}
const handleRevokeInvite = async (invite: TeamInvitation) => {
setRevokingId(invite.id)
try {
const res = await fetch(`/api/team/invite/${invite.id}`, { method: 'DELETE' })
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('revoke_failed')), variant: 'destructive' })
return
}
// A revoked invitation's link is dead: never keep offering it.
setShareInvite((current) => (current?.email === invite.email ? null : current))
toast({ title: t('revoked_toast') })
} catch {
toast({ title: t('revoke_failed'), variant: 'destructive' })
} finally {
setRevokingId(null)
void fetchMembers()
}
}
const handleResendInvite = async (invite: TeamInvitation) => {
setResendingId(invite.id)
try {
const res = await fetch(`/api/team/invite/${invite.id}`, { method: 'POST' })
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('resend_failed')), variant: 'destructive' })
return
}
const payload = (data as { data?: { email_sent?: boolean; inviteUrl?: string } } | null)?.data
if (payload?.inviteUrl) {
setShareInvite({
email: invite.email,
url: payload.inviteUrl,
sent: payload.email_sent !== false,
})
}
toast({
title: t('resend_toast'),
description: payload?.email_sent === false ? t('invite_mail_not_sent') : undefined,
})
void fetchMembers()
} catch {
toast({ title: t('resend_failed'), variant: 'destructive' })
} finally {
setResendingId(null)
}
}
const handleCopyInviteLink = async (url: string) => {
try {
await navigator.clipboard.writeText(url)
toast({ title: t('invite_link_copied_toast') })
} catch {
toast({ title: t('invite_link_copy_failed'), variant: 'destructive' })
}
}
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault()
const email = inviteEmail.trim().toLowerCase()
if (!email) return
setIsSending(true)
try {
const res = await fetch('/api/team/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, role: inviteRole, ...(teamId ? { teamId } : {}) }),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('invite_failed')), variant: 'destructive' })
return
}
const payload = (data as { data?: { email_sent?: boolean; inviteUrl?: string } } | null)?.data
const sent = payload?.email_sent
// Persist the shareable link next to the pending list: a failed send
// leaves the invitation valid, and the toast alone is too easy to miss
// (the Arbore case), so the recovery path stays visible on the page.
if (payload?.inviteUrl) {
setShareInvite({ email, url: payload.inviteUrl, sent: sent !== false })
}
toast({
title: t('invite_sent_toast'),
description: sent === false ? t('invite_mail_not_sent') : undefined,
})
setInviteEmail('')
setInviteRole('member')
void fetchMembers()
} catch {
toast({ title: t('invite_failed'), variant: 'destructive' })
} finally {
setIsSending(false)
}
}
if (members === null) {
return (
<div>
{/* Live region always mounted while the roster is unknown, so the
failure is announced when it appears, not merely inserted. */}
<div role="status" aria-live="polite" className="min-w-0 px-1">
{loadError && (
<AttnLine
action={
loadError.detail
? undefined
: { label: t('load_retry'), onClick: () => setReloadKey((k) => k + 1) }
}
>
{loadError.detail
? `${t('load_failed')} ${loadError.detail}`
: t('load_failed')}
</AttnLine>
)}
</div>
{!loadError && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
</div>
)
}
return (
<SettingsGroup label={<span data-ph-mask="">{teamName || t('team_fallback')}</span>}>
{/* Member roster: flat hairline rows, no cards. */}
{members.map((member) => {
// Role select gates mirror the API: own row stays read-only (no
// accidental self-demotion), owner rows are owner-managed, and the
// owner option itself is owner-granted.
const roleEditable = canManage && !member.is_current_user && (member.role !== 'owner' || isOwner)
const removable = canManage && !member.is_current_user && (member.role !== 'owner' || isOwner)
return (
<div
key={member.id}
className="flex items-center gap-3 border-b border-border px-1 py-3"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted/60">
<span className="text-xs font-medium text-muted-foreground">
{member.email.charAt(0).toUpperCase()}
</span>
</div>
<p className="min-w-0 flex-1 truncate text-sm">
{member.email}
{member.is_current_user && (
<span className="ml-1 text-muted-foreground">{t('you_suffix')}</span>
)}
</p>
{roleEditable ? (
<Select
value={member.role}
onValueChange={(value) => void handleRoleChange(member, value)}
disabled={changingRoleId === member.id}
>
<SelectTrigger
className={cn(ROLE_TRIGGER_CLASS, 'text-xs')}
aria-label={t('role_change_aria', { email: member.email })}
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{isOwner && <SelectItem value="owner">{t('role_owner')}</SelectItem>}
<SelectItem value="admin">{t('role_admin')}</SelectItem>
<SelectItem value="member">{t('role_member')}</SelectItem>
</SelectContent>
</Select>
) : (
<span className="shrink-0 text-xs text-muted-foreground">
{roleLabel(member.role)}
</span>
)}
{removable && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('remove_aria', { email: member.email })}
onClick={() => setRemoveTarget(member)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
)
})}
{/* Pending invitations continue the same list, visually quieter. */}
{canManage &&
invitations.map((inv) => {
const expired = new Date(inv.expires_at) <= new Date()
return (
<div
key={inv.id}
className="flex items-center gap-3 border-b border-border px-1 py-3"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-dashed border-border">
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{inv.email}
<span className="ml-1 text-xs">
·{' '}
{expired
? t('invite_expired')
: t('invite_expires', { date: formatDateLong(inv.expires_at) })}
</span>
</p>
<span className="shrink-0 text-xs text-muted-foreground">
{roleLabel(inv.role)}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={t('resend_aria', { email: inv.email })}
onClick={() => void handleResendInvite(inv)}
disabled={resendingId === inv.id || revokingId === inv.id}
>
{resendingId === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('revoke_aria', { email: inv.email })}
onClick={() => void handleRevokeInvite(inv)}
disabled={revokingId === inv.id || resendingId === inv.id}
>
{revokingId === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
</div>
)
})}
{/* Shareable accept link from the latest invite/re-send: one sentence,
not a banner. The failed-send case is the page's single attn line;
the sent case is a quiet muted line with the same copy action, since
the link is a legitimate share path either way. */}
{canManage && shareInvite && (
<div className="px-1 pt-3" role="status" aria-live="polite">
{shareInvite.sent ? (
<p className="text-[12.5px] leading-5 text-muted-foreground">
{t('invite_link_sent', { email: shareInvite.email })}{' '}
<button
type="button"
onClick={() => void handleCopyInviteLink(shareInvite.url)}
className="underline underline-offset-2 hover:text-foreground"
>
{t('invite_link_copy_action')}
</button>
</p>
) : (
<AttnLine
action={{
label: t('invite_link_copy_action'),
onClick: () => void handleCopyInviteLink(shareInvite.url),
}}
>
{t('invite_link_not_sent', { email: shareInvite.email })}
</AttnLine>
)}
</div>
)}
{/* Inline invite: the list's own last row instead of a separate card. */}
{canManage && (
<form
onSubmit={handleInvite}
className="flex flex-col gap-3 px-1 pt-3 sm:flex-row sm:items-center"
>
<label htmlFor="team-invite-email" className="sr-only">
{t('invite_email_label')}
</label>
<SettingsInput
id="team-invite-email"
type="email"
placeholder={t('invite_email_placeholder')}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={isSending}
required
className="border-border sm:flex-1"
/>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger className={ROLE_TRIGGER_CLASS} aria-label={t('invite_role_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="member">{t('role_member')}</SelectItem>
<SelectItem value="admin">{t('role_admin')}</SelectItem>
</SelectContent>
</Select>
<Button type="submit" size="sm" disabled={isSending || !inviteEmail.trim()}>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="mr-2 h-4 w-4" />
{t('invite_button')}
</>
)}
</Button>
</form>
)}
{/* Confirm up front: the dialog describes the outcome (loses access to
every client company via the team) before anything is deleted. */}
<ConfirmDialog
open={removeTarget !== null}
onOpenChange={(open) => {
if (!open) setRemoveTarget(null)
}}
title={t('remove_confirm_title')}
description={
removeTarget
? t('remove_confirm_body', {
email: removeTarget.email,
team: teamName || t('team_fallback'),
})
: undefined
}
confirmLabel={t('remove_confirm_button')}
destructive
onConfirm={async () => {
if (removeTarget) await handleRemove(removeTarget)
}}
/>
</SettingsGroup>
)
}